Merge main into M13.7
This commit is contained in:
commit
cbf9e40528
|
|
@ -282,9 +282,10 @@ fn iris_spans(r: f64, sides: u32, rotation_deg: f64, roundness: f64) -> Vec<(i64
|
|||
pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64) {
|
||||
let shape = ctx.params.e("irisProperties/irisShape");
|
||||
let sides = if shape >= 8 { 0 } else { shape + 3 };
|
||||
let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0);
|
||||
let roundness = ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0;
|
||||
// Aspect Ratio stretches the iris horizontally (> 1) or squeezes it (< 1).
|
||||
let aspect = ctx.params.f("irisProperties/irisAspectRatio");
|
||||
let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), roundness);
|
||||
if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 {
|
||||
for s in spans.iter_mut() {
|
||||
s.1 = (s.1 as f64 * aspect).round() as i64;
|
||||
|
|
@ -294,12 +295,23 @@ pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64)
|
|||
(spans, r * aspect.max(1.0))
|
||||
}
|
||||
|
||||
/// Whether Camera Lens Blur needs more than plain iris spans (Diffraction Fringe or a Blur
|
||||
/// Map): the GPU kernel handles the plain blur only.
|
||||
pub fn camera_lens_plain(ctx: &EffectCtx) -> bool {
|
||||
ctx.params.f("irisProperties/diffractionFringe") <= 0.0 && ctx.params.get("blurMap/blurMapLayer").and_then(|v| v.as_layer()).is_none()
|
||||
}
|
||||
|
||||
fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let r = ctx.params.f("blurRadius").max(0.0) * b.scale;
|
||||
if r < 0.5 {
|
||||
return b;
|
||||
}
|
||||
let (spans, reach) = camera_lens_spans(ctx, r);
|
||||
let shape = ctx.params.e("irisProperties/irisShape");
|
||||
let sides = if shape >= 8 { 0 } else { shape + 3 };
|
||||
let roundness = ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0;
|
||||
// Aspect Ratio stretches the iris horizontally (> 1) or squeezes it (< 1).
|
||||
let aspect = ctx.params.f("irisProperties/irisAspectRatio");
|
||||
let reach = r * aspect.max(1.0);
|
||||
let repeat = ctx.params.b("repeatEdge") || ctx.adjustment;
|
||||
if !repeat {
|
||||
b.pad(reach.ceil() as u32 + 1);
|
||||
|
|
@ -348,58 +360,148 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
v
|
||||
})
|
||||
.collect();
|
||||
let count: i64 = spans.iter().map(|(_, l, r)| r - l + 1).sum();
|
||||
let norm = 1.0 / count as f64;
|
||||
let mut out = Image::new(src.width, src.height);
|
||||
out.rows_mut().for_each(|(y, row)| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let mut acc = [0.0f64; 4];
|
||||
for &(dy, l, rr) in &spans {
|
||||
let mut yy = y as i64 + dy;
|
||||
if yy < 0 || yy >= h as i64 {
|
||||
if !repeat {
|
||||
continue;
|
||||
}
|
||||
yy = yy.clamp(0, h as i64 - 1);
|
||||
// Sum of the source over the iris spans centred on (x, y).
|
||||
let span_sum = |spans: &[(i64, i64, i64)], x: usize, y: usize| -> [f64; 4] {
|
||||
let mut acc = [0.0f64; 4];
|
||||
for &(dy, l, rr) in spans {
|
||||
let mut yy = y as i64 + dy;
|
||||
if yy < 0 || yy >= h as i64 {
|
||||
if !repeat {
|
||||
continue;
|
||||
}
|
||||
let pre = &prefix[yy as usize];
|
||||
let (mut a, mut bnd) = (x as i64 + l, x as i64 + rr);
|
||||
if repeat {
|
||||
if a < 0 {
|
||||
let n = (-a).min(bnd - a + 1) as f64;
|
||||
let first = src.data[yy as usize * w];
|
||||
for c in 0..4 {
|
||||
acc[c] += first[c] as f64 * n;
|
||||
}
|
||||
a = 0;
|
||||
}
|
||||
if bnd >= w as i64 {
|
||||
let n = (bnd - (w as i64 - 1)).min(bnd - a + 1) as f64;
|
||||
let last = src.data[yy as usize * w + w - 1];
|
||||
for c in 0..4 {
|
||||
acc[c] += last[c] as f64 * n;
|
||||
}
|
||||
bnd = w as i64 - 1;
|
||||
}
|
||||
} else {
|
||||
a = a.max(0);
|
||||
bnd = bnd.min(w as i64 - 1);
|
||||
}
|
||||
if a <= bnd {
|
||||
yy = yy.clamp(0, h as i64 - 1);
|
||||
}
|
||||
let pre = &prefix[yy as usize];
|
||||
let (mut a, mut bnd) = (x as i64 + l, x as i64 + rr);
|
||||
if repeat {
|
||||
if a < 0 {
|
||||
let n = (-a).min(bnd - a + 1) as f64;
|
||||
let first = src.data[yy as usize * w];
|
||||
for c in 0..4 {
|
||||
acc[c] += pre[bnd as usize + 1][c] - pre[a as usize][c];
|
||||
acc[c] += first[c] as f64 * n;
|
||||
}
|
||||
a = 0;
|
||||
}
|
||||
if bnd >= w as i64 {
|
||||
let n = (bnd - (w as i64 - 1)).min(bnd - a + 1) as f64;
|
||||
let last = src.data[yy as usize * w + w - 1];
|
||||
for c in 0..4 {
|
||||
acc[c] += last[c] as f64 * n;
|
||||
}
|
||||
bnd = w as i64 - 1;
|
||||
}
|
||||
} else {
|
||||
a = a.max(0);
|
||||
bnd = bnd.min(w as i64 - 1);
|
||||
}
|
||||
if a <= bnd {
|
||||
for c in 0..4 {
|
||||
acc[c] += pre[bnd as usize + 1][c] - pre[a as usize][c];
|
||||
}
|
||||
}
|
||||
let mut px = acc.map(|v| (v * norm) as f32);
|
||||
px[3] = px[3].clamp(0.0, 1.0);
|
||||
if linear {
|
||||
let (c, a) = unpremul(px);
|
||||
px = premul(c.map(|v| effectcraft_color::linear_to_srgb(v.max(0.0))), a);
|
||||
}
|
||||
*o = px;
|
||||
}
|
||||
acc
|
||||
};
|
||||
// Diffraction Fringe: the iris edge (outer 20 % of the radius) gets more of the energy:
|
||||
// 0 = an even disc, 100 = a natural halo, 500 = all of it in the ring.
|
||||
let fringe = (ctx.params.f("irisProperties/diffractionFringe") / 500.0).clamp(0.0, 1.0);
|
||||
let (w_in, w_ring) = (1.0 - fringe, 1.0 + 4.0 * fringe);
|
||||
let iris = |radius: f64| -> (Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>) {
|
||||
let stretch = |mut s: Vec<(i64, i64, i64)>| {
|
||||
if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 {
|
||||
for v in s.iter_mut() {
|
||||
v.1 = (v.1 as f64 * aspect).round() as i64;
|
||||
v.2 = (v.2 as f64 * aspect).round() as i64;
|
||||
}
|
||||
}
|
||||
s
|
||||
};
|
||||
let outer = stretch(iris_spans(radius, sides, ctx.params.f("irisProperties/irisRotation"), roundness));
|
||||
let inner = if fringe > 0.0 && radius * 0.8 >= 1.0 {
|
||||
stretch(iris_spans(radius * 0.8, sides, ctx.params.f("irisProperties/irisRotation"), roundness))
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
(outer, inner)
|
||||
};
|
||||
let count = |s: &[(i64, i64, i64)]| s.iter().map(|(_, l, r)| (r - l + 1) as f64).sum::<f64>();
|
||||
// One blurred pixel with the given iris.
|
||||
let blur_px = |sp: &(Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>), x: usize, y: usize| -> [f32; 4] {
|
||||
let (outer, inner) = sp;
|
||||
let acc = if inner.is_empty() {
|
||||
let n = count(outer).max(1.0);
|
||||
span_sum(outer, x, y).map(|v| v / n)
|
||||
} else {
|
||||
let (so, si) = (span_sum(outer, x, y), span_sum(inner, x, y));
|
||||
let (no, ni) = (count(outer), count(inner));
|
||||
let norm = (w_in * ni + w_ring * (no - ni)).max(1e-9);
|
||||
std::array::from_fn(|c| (w_in * si[c] + w_ring * (so[c] - si[c])) / norm)
|
||||
};
|
||||
let mut px = acc.map(|v| v as f32);
|
||||
px[3] = px[3].clamp(0.0, 1.0);
|
||||
if linear {
|
||||
let (c, a) = unpremul(px);
|
||||
px = premul(c.map(|v| effectcraft_color::linear_to_srgb(v.max(0.0))), a);
|
||||
}
|
||||
px
|
||||
};
|
||||
// Blur Map: per-pixel radius from a map layer (no blur at Blur Focal Distance, full radius
|
||||
// at the farthest value).
|
||||
let map = ctx.layer_param("blurMap/blurMapLayer", true).map(|o| {
|
||||
let stretch = ctx.params.e("blurMap/placement") == 1;
|
||||
let img = crate::util::fit_layer(ctx, &b, &o, stretch);
|
||||
let ch = ctx.params.e("blurMap/channel");
|
||||
let invert = ctx.params.b("blurMap/invertBlurMap");
|
||||
let focal = (ctx.params.f("blurMap/blurFocalDistance") / 255.0).clamp(0.0, 1.0) as f32;
|
||||
let span = focal.max(1.0 - focal).max(1e-3);
|
||||
img.data
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (c, a) = unpremul(*p);
|
||||
let mut v = match ch {
|
||||
1 => a,
|
||||
2 => c[0].max(c[1]).max(c[2]) * a,
|
||||
_ => luminance(c[0], c[1], c[2]) * a,
|
||||
};
|
||||
if invert {
|
||||
v = 1.0 - v;
|
||||
}
|
||||
((v - focal).abs() / span).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect::<Vec<f32>>()
|
||||
});
|
||||
let mut out = Image::new(src.width, src.height);
|
||||
match &map {
|
||||
None => {
|
||||
let sp = iris(r);
|
||||
out.rows_mut().for_each(|(y, row)| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = blur_px(&sp, x, y);
|
||||
}
|
||||
});
|
||||
}
|
||||
Some(m) => {
|
||||
// Radii quantised to levels; each pixel blends the two nearest.
|
||||
let levels = (r.ceil() as usize).clamp(1, 8);
|
||||
let irises: Vec<_> = (0..=levels).map(|k| iris(r * k as f64 / levels as f64)).collect();
|
||||
let srcd = &b.img.data;
|
||||
out.rows_mut().for_each(|(y, row)| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let f = m[y * w + x] * levels as f32;
|
||||
let k0 = (f.floor() as usize).min(levels);
|
||||
let t = f - k0 as f32;
|
||||
let at = |k: usize| if k == 0 || r * k as f64 / (levels as f64) < 0.5 { srcd[y * w + x] } else { blur_px(&irises[k], x, y) };
|
||||
let a = at(k0);
|
||||
*o = if t > 1e-4 && k0 < levels {
|
||||
let bq = at(k0 + 1);
|
||||
std::array::from_fn(|c| a[c] + (bq[c] - a[c]) * t)
|
||||
} else {
|
||||
a
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
b.img = out;
|
||||
b
|
||||
}
|
||||
|
|
@ -604,6 +706,12 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("irisProperties/irisRoundness", "Roundness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("irisProperties/irisAspectRatio", "Aspect Ratio", num(1.0), slider(0.01, 100.0, 0.25, 4.0, 2)),
|
||||
p("irisProperties/irisRotation", "Rotation", num(0.0), ParamUi::Angle),
|
||||
p("irisProperties/diffractionFringe", "Diffraction Fringe", num(0.0), slider(0.0, 500.0, 0.0, 500.0, 1)),
|
||||
p("blurMap/blurMapLayer", "Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("blurMap/channel", "Channel", Value::Enum(0), popup(&["Luminance", "Alpha", "Color"])),
|
||||
p("blurMap/placement", "Placement", Value::Enum(1), popup(&["Center", "Stretch"])),
|
||||
p("blurMap/blurFocalDistance", "Blur Focal Distance", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
|
||||
p("blurMap/invertBlurMap", "Invert Blur Map", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("highlight/specularBrightness", "Gain", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("highlight/specularThreshold", "Threshold", num(255.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
|
||||
p("highlight/highlightSaturation", "Saturation", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
|
|
@ -822,4 +930,38 @@ mod tests {
|
|||
let pad = (out.img.width - 16) / 2;
|
||||
assert!(out.img.get(pad as i64, pad as i64)[0] < 0.005);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_lens_blur_diffraction_fringe_and_blur_map() {
|
||||
use crate::{EffectEnv, EffectHost, LayerPixels};
|
||||
// A single bright point spreads into a disc; with Diffraction Fringe its rim is brighter.
|
||||
let mut dot = Image::filled(41, 41, [0.0, 0.0, 0.0, 1.0]);
|
||||
dot.set(20, 20, [1.0, 1.0, 1.0, 1.0]);
|
||||
let v = [("blurRadius", num(10.0)), ("irisProperties/irisShape", Value::Enum(8)), ("repeatEdge", Value::Bool(true))];
|
||||
let plain = run("ec.blur.cameralens", &dot, &v).img;
|
||||
let ring = run("ec.blur.cameralens", &dot, &[v.as_slice(), &[("irisProperties/diffractionFringe", num(300.0))]].concat()).img;
|
||||
assert!((plain.get(20, 20)[0] - plain.get(29, 20)[0]).abs() < 1e-5, "even disc");
|
||||
assert!(ring.get(29, 20)[0] > ring.get(20, 20)[0] * 2.0, "{:?} {:?}", ring.get(29, 20), ring.get(20, 20));
|
||||
// Blur Map: black (focal distance 0) stays sharp, white gets the full radius.
|
||||
struct Half;
|
||||
impl EffectHost for Half {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
let mut m = Image::filled(40, 20, [0.0, 0.0, 0.0, 1.0]);
|
||||
for y in 0..20 {
|
||||
for x in 20..40 {
|
||||
m.set(x, y, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
Some(LayerPixels { buf: Buf { img: m, offset: [0.0; 2], scale: 1.0 }, size: [40.0, 20.0] })
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let stripes = crate::util::gen_image(40, 20, |x, _| if x % 4 < 2 { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] });
|
||||
let vals = [("blurRadius", num(6.0)), ("repeatEdge", Value::Bool(true)), ("blurMap/blurMapLayer", Value::Layer(Some(1)))];
|
||||
let out = crate::run_fx("ec.blur.cameralens", &vals, stripes.clone(), 0.0, EffectEnv { host: Some(&Half), ..Default::default() }).img;
|
||||
assert_eq!(out.get(4, 10), stripes.get(4, 10), "sharp where the map is at the focal distance");
|
||||
assert!((out.get(30, 10)[0] - 0.5).abs() < 0.2, "{:?}", out.get(30, 10));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,413 @@
|
|||
//! The 3D system shared by Card Dance, Shatter and Card Wipe: Camera System (Camera Position,
|
||||
//! Corner Pins, Comp Camera), Lighting (Distant Source, Point Source, First Comp Light) and
|
||||
//! Material (diffuse, specular, highlight sharpness).
|
||||
//!
|
||||
//! Pieces live in "buffer world" space: x, y in buffer pixels, z in buffer pixels away from the
|
||||
//! viewer (the layer plane is z = 0). A [`Proj`] maps buffer-world points to buffer pixels
|
||||
//! (homogeneous), so every camera system reduces to one 3×4 matrix.
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
|
||||
use crate::sim::pt;
|
||||
use crate::{Buf, EffectCtx, ParamSpec, col, num, p, popup, slider};
|
||||
|
||||
pub(crate) type M3 = [[f64; 3]; 3];
|
||||
|
||||
/// Camera System options.
|
||||
pub const CAMERA_SYSTEMS: [&str; 3] = ["Camera Position", "Corner Pins", "Comp Camera"];
|
||||
/// Transform Order options (rotation order, and whether the camera position applies before or
|
||||
/// after the rotation).
|
||||
pub const TRANSFORM_ORDERS: [&str; 12] = [
|
||||
"Rotate XYZ, Position",
|
||||
"Rotate XZY, Position",
|
||||
"Rotate YXZ, Position",
|
||||
"Rotate YZX, Position",
|
||||
"Rotate ZXY, Position",
|
||||
"Rotate ZYX, Position",
|
||||
"Position, Rotate XYZ",
|
||||
"Position, Rotate XZY",
|
||||
"Position, Rotate YXZ",
|
||||
"Position, Rotate YZX",
|
||||
"Position, Rotate ZXY",
|
||||
"Position, Rotate ZYX",
|
||||
];
|
||||
/// Light Type options.
|
||||
pub const LIGHT_TYPES: [&str; 3] = ["Distant Source", "Point Source", "First Comp Light"];
|
||||
|
||||
/// Camera System, Camera Position and Corner Pins parameters. `z` is the default camera
|
||||
/// distance (Z Position, in layer sizes at the 70 mm-equivalent Focal Length).
|
||||
pub(crate) fn camera_params(z: f64) -> Vec<ParamSpec> {
|
||||
vec![
|
||||
p("cameraSystem", "Camera System", Value::Enum(0), popup(&CAMERA_SYSTEMS)),
|
||||
p("cameraPosition/xRotation", "X Rotation", num(0.0), ParamUi::Angle),
|
||||
p("cameraPosition/yRotation", "Y Rotation", num(0.0), ParamUi::Angle),
|
||||
p("cameraPosition/zRotation", "Z Rotation", num(0.0), ParamUi::Angle),
|
||||
p("cameraPosition/xyPosition", "X, Y Position", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("cameraPosition/cameraZ", "Z Position", num(z), slider(0.05, 10.0, 0.5, 5.0, 2)),
|
||||
p("cameraPosition/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)),
|
||||
p("cameraPosition/transformOrder", "Transform Order", Value::Enum(0), popup(&TRANSFORM_ORDERS)),
|
||||
p("cornerPins/upperLeftCorner", "Upper Left Corner", pt(0.0, 0.0), ParamUi::Point),
|
||||
p("cornerPins/upperRightCorner", "Upper Right Corner", pt(1.0, 0.0), ParamUi::Point),
|
||||
p("cornerPins/lowerLeftCorner", "Lower Left Corner", pt(0.0, 1.0), ParamUi::Point),
|
||||
p("cornerPins/lowerRightCorner", "Lower Right Corner", pt(1.0, 1.0), ParamUi::Point),
|
||||
p("cornerPins/autoFocalLength", "Auto Focal Length", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("cornerPins/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Lighting parameters.
|
||||
pub(crate) fn lighting_params(intensity: f64, ambient: f64) -> Vec<ParamSpec> {
|
||||
vec![
|
||||
p("lighting/lightType", "Light Type", Value::Enum(0), popup(&LIGHT_TYPES)),
|
||||
p("lighting/lightIntensity", "Light Intensity", num(intensity), slider(0.0, 4.0, 0.0, 2.0, 2)),
|
||||
p("lighting/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("lighting/lightPosition", "Light Position", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("lighting/lightDepth", "Light Depth", num(1.0), slider(-10.0, 10.0, 0.0, 5.0, 3)),
|
||||
p("lighting/ambientLight", "Ambient Light", num(ambient), slider(0.0, 2.0, 0.0, 1.0, 2)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Material parameters. Specular defaults to 0 so pieces at rest reproduce the layer.
|
||||
pub(crate) fn material_params(diffuse: f64) -> Vec<ParamSpec> {
|
||||
vec![
|
||||
p("material/diffuse", "Diffuse Reflection", num(diffuse), slider(0.0, 2.0, 0.0, 1.0, 2)),
|
||||
p("material/specular", "Specular Reflection", num(0.0), slider(0.0, 2.0, 0.0, 1.0, 2)),
|
||||
p("material/highlightSharpness", "Highlight Sharpness", num(15.0), slider(1.0, 100.0, 1.0, 100.0, 1)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether the camera / lighting parameter `param` is shown for the current choices.
|
||||
pub(crate) fn shown(param: &str, e: &dyn Fn(&str) -> u32) -> Option<bool> {
|
||||
if param == "cameraPosition" || param.starts_with("cameraPosition/") {
|
||||
return Some(e("cameraSystem") == 0);
|
||||
}
|
||||
if param == "cornerPins" || param.starts_with("cornerPins/") {
|
||||
return Some(e("cameraSystem") == 1);
|
||||
}
|
||||
if param == "lighting/lightPosition" || param == "lighting/lightDepth" {
|
||||
return Some(e("lighting/lightType") != 2);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn mat_mul(a: &M3, b: &M3) -> M3 {
|
||||
std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum()))
|
||||
}
|
||||
|
||||
fn rx(a: f64) -> M3 {
|
||||
let (s, c) = a.sin_cos();
|
||||
[[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]]
|
||||
}
|
||||
fn ry(a: f64) -> M3 {
|
||||
let (s, c) = a.sin_cos();
|
||||
[[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]]
|
||||
}
|
||||
fn rz(a: f64) -> M3 {
|
||||
let (s, c) = a.sin_cos();
|
||||
[[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]
|
||||
}
|
||||
|
||||
/// Rotation applying the axes in `order` (0 XYZ, 1 XZY, 2 YXZ, 3 YZX, 4 ZXY, 5 ZYX; the first
|
||||
/// letter is applied first). Angles in radians.
|
||||
pub(crate) fn rot_ordered(x: f64, y: f64, z: f64, order: u32) -> M3 {
|
||||
let (mx, my, mz) = (rx(x), ry(y), rz(z));
|
||||
let seq: [&M3; 3] = match order % 6 {
|
||||
1 => [&mx, &mz, &my],
|
||||
2 => [&my, &mx, &mz],
|
||||
3 => [&my, &mz, &mx],
|
||||
4 => [&mz, &mx, &my],
|
||||
5 => [&mz, &my, &mx],
|
||||
_ => [&mx, &my, &mz],
|
||||
};
|
||||
mat_mul(seq[2], &mat_mul(seq[1], seq[0]))
|
||||
}
|
||||
|
||||
/// A camera: buffer-world → buffer pixels (homogeneous rows X, Y, W; W is the depth in front
|
||||
/// of the camera, in the units `near` uses).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Proj {
|
||||
pub m: [[f64; 4]; 3],
|
||||
/// Points with W below this are behind / too close to the camera.
|
||||
pub near: f64,
|
||||
}
|
||||
|
||||
impl Proj {
|
||||
/// Looking down +z at the layer plane from `d` pixels in front of buffer point `c`.
|
||||
pub fn simple(c: [f64; 2], d: f64) -> Proj {
|
||||
Proj { m: [[d, 0.0, c[0], 0.0], [0.0, d, c[1], 0.0], [0.0, 0.0, 1.0, d]], near: 0.05 * d }
|
||||
}
|
||||
|
||||
/// `self` after the affine scene transform `p ↦ a·p + t`.
|
||||
fn after(&self, a: &M3, t: [f64; 3]) -> Proj {
|
||||
let mut m = [[0.0; 4]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
m[i][j] = (0..3).map(|k| self.m[i][k] * a[k][j]).sum();
|
||||
}
|
||||
m[i][3] = (0..3).map(|k| self.m[i][k] * t[k]).sum::<f64>() + self.m[i][3];
|
||||
}
|
||||
Proj { m, near: self.near }
|
||||
}
|
||||
|
||||
/// A screen-space homography applied after `self`.
|
||||
fn then(&self, h: &M3) -> Proj {
|
||||
let mut m = [[0.0; 4]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..4 {
|
||||
m[i][j] = (0..3).map(|k| h[i][k] * self.m[k][j]).sum();
|
||||
}
|
||||
}
|
||||
// Keep W's scale (and so `near`) comparable when H rescales it.
|
||||
Proj { m, near: self.near * h[2][2].abs().max(1e-9) }
|
||||
}
|
||||
|
||||
/// Project a buffer-world point (None behind the camera).
|
||||
#[cfg(test)]
|
||||
pub fn project(&self, p: [f64; 3]) -> Option<[f64; 2]> {
|
||||
let r = |i: usize| self.m[i][0] * p[0] + self.m[i][1] * p[1] + self.m[i][2] * p[2] + self.m[i][3];
|
||||
let w = r(2);
|
||||
(w > self.near).then(|| [r(0) / w, r(1) / w])
|
||||
}
|
||||
|
||||
/// The camera's centre in buffer-world space (where the projection collapses), or a point
|
||||
/// far in front of the layer when it has none (orthographic).
|
||||
pub fn eye(&self) -> [f64; 3] {
|
||||
let a: M3 = std::array::from_fn(|i| [self.m[i][0], self.m[i][1], self.m[i][2]]);
|
||||
match inv3(&a) {
|
||||
Some(ai) => {
|
||||
let t = [-self.m[0][3], -self.m[1][3], -self.m[2][3]];
|
||||
std::array::from_fn(|i| (0..3).map(|k| ai[i][k] * t[k]).sum())
|
||||
}
|
||||
None => [0.0, 0.0, -1e9],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn inv3(m: &M3) -> Option<M3> {
|
||||
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
|
||||
if det.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let d = 1.0 / det;
|
||||
Some([
|
||||
[(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * d, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * d, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * d],
|
||||
[(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * d, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * d, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * d],
|
||||
[(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * d, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * d, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * d],
|
||||
])
|
||||
}
|
||||
|
||||
/// Homography taking the unit square's corners (0,0) (1,0) (1,1) (0,1) to `q`.
|
||||
fn square_to_quad(q: [[f64; 2]; 4]) -> M3 {
|
||||
let v = |p: [f64; 2]| effectcraft_geom::Vec2 { x: p[0], y: p[1] };
|
||||
effectcraft_geom::Mat3::square_to_quad([v(q[0]), v(q[1]), v(q[2]), v(q[3])]).0
|
||||
}
|
||||
|
||||
/// The camera an effect renders through, from its Camera System parameters.
|
||||
pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj {
|
||||
let pr = ctx.params;
|
||||
let [lw, lh] = ctx.layer_size;
|
||||
let s = b.scale;
|
||||
let c = [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s];
|
||||
let size = lw.max(lh) * s;
|
||||
let f = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d);
|
||||
match pr.e("cameraSystem") {
|
||||
1 => {
|
||||
// Corner Pins: the layer's corners land on the four pins; the focal length sets how
|
||||
// strongly depth (pieces leaving the plane) is foreshortened.
|
||||
let focal = if pr.get("cornerPins/autoFocalLength").is_none_or(|v| v.as_bool()) { 70.0 } else { f("cornerPins/focalLength", 70.0).max(1.0) };
|
||||
let base = Proj::simple(c, size * 2.0 * focal / 70.0);
|
||||
let pin = |id: &str| {
|
||||
let (x, y) = b.to_px(pr.v2(id));
|
||||
[x, y]
|
||||
};
|
||||
let quad =
|
||||
[pin("cornerPins/upperLeftCorner"), pin("cornerPins/upperRightCorner"), pin("cornerPins/lowerRightCorner"), pin("cornerPins/lowerLeftCorner")];
|
||||
let rect = [
|
||||
[b.offset[0], b.offset[1]],
|
||||
[b.offset[0] + lw * s, b.offset[1]],
|
||||
[b.offset[0] + lw * s, b.offset[1] + lh * s],
|
||||
[b.offset[0], b.offset[1] + lh * s],
|
||||
];
|
||||
match inv3(&square_to_quad(rect)) {
|
||||
Some(ri) => base.then(&mat_mul(&square_to_quad(quad), &ri)),
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Comp Camera: the comp's camera looking at this layer (layer px → layer px).
|
||||
let base = Proj::simple(c, size * 2.0);
|
||||
let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.camera) else { return base };
|
||||
// buffer world → layer: (p − offset) / s; layer → buffer: · s + offset.
|
||||
let mut m = [[0.0; 4]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
m[i][j] = cam[i][j] / s;
|
||||
}
|
||||
m[i][3] = cam[i][3] - (cam[i][0] * b.offset[0] + cam[i][1] * b.offset[1]) / s;
|
||||
}
|
||||
let back: M3 = [[s, 0.0, b.offset[0]], [0.0, s, b.offset[1]], [0.0, 0.0, 1.0]];
|
||||
let p = Proj { m, near: 1e-6 }.then(&back);
|
||||
// W of the layer centre sets the scale of `near`.
|
||||
let wc = p.m[2][0] * c[0] + p.m[2][1] * c[1] + p.m[2][3];
|
||||
Proj { near: (wc.abs() * 0.02).max(1e-9), ..p }
|
||||
}
|
||||
_ => {
|
||||
let zpos = f("cameraPosition/cameraZ", 2.0).max(0.05);
|
||||
let focal = f("cameraPosition/focalLength", 70.0).max(1.0);
|
||||
let base = Proj::simple(c, size * zpos * focal / 140.0);
|
||||
let order = pr.e("cameraPosition/transformOrder");
|
||||
let r = rot_ordered(
|
||||
f("cameraPosition/xRotation", 0.0).to_radians(),
|
||||
f("cameraPosition/yRotation", 0.0).to_radians(),
|
||||
f("cameraPosition/zRotation", 0.0).to_radians(),
|
||||
order,
|
||||
);
|
||||
let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2()).unwrap_or([lw * 0.5, lh * 0.5]);
|
||||
let (px, py) = b.to_px(xy);
|
||||
let shift = [px - c[0], py - c[1], 0.0];
|
||||
// Rotate about the layer centre, then move by the X, Y Position offset (or the
|
||||
// other way round).
|
||||
let c3 = [c[0], c[1], 0.0];
|
||||
let rc: [f64; 3] = std::array::from_fn(|i| c3[i] - (0..3).map(|k| r[i][k] * c3[k]).sum::<f64>());
|
||||
let t = if order >= 6 {
|
||||
// p ↦ R(p + shift − c) + c
|
||||
std::array::from_fn(|i| rc[i] + (0..3).map(|k| r[i][k] * shift[k]).sum::<f64>())
|
||||
} else {
|
||||
std::array::from_fn(|i| rc[i] + shift[i])
|
||||
};
|
||||
base.after(&r, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The comp's first light as the effect sees it (layer pixels), from [`crate::EffectHost::comp_scene`].
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct CompLight {
|
||||
/// Position in layer pixels (z away from the viewer).
|
||||
pub pos: [f64; 3],
|
||||
/// Direction the light travels (parallel / spot lights), unit, layer space.
|
||||
pub dir: [f64; 3],
|
||||
/// Colour × intensity (1 = 100 %).
|
||||
pub color: [f32; 3],
|
||||
/// 0 parallel, 1 point / spot, 2 ambient.
|
||||
pub kind: u8,
|
||||
}
|
||||
|
||||
/// The comp's camera and lights relative to the effect's layer.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct CompScene {
|
||||
/// Layer pixels (x, y, z) → layer pixels, homogeneous rows X, Y, W: the comp camera's view
|
||||
/// of the layer brought back into the layer's own pixel grid (identity on the layer plane
|
||||
/// for the default camera).
|
||||
pub camera: Option<[[f64; 4]; 3]>,
|
||||
pub light: Option<CompLight>,
|
||||
}
|
||||
|
||||
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
fn norm(a: [f64; 3]) -> [f64; 3] {
|
||||
let l = dot(a, a).sqrt();
|
||||
if l < 1e-12 { [0.0, 0.0, -1.0] } else { [a[0] / l, a[1] / l, a[2] / l] }
|
||||
}
|
||||
|
||||
/// Lighting and Material, ready to shade pieces.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct Lighting {
|
||||
/// 0 distant, 1 point, 2 ambient only.
|
||||
kind: u8,
|
||||
color: [f32; 3],
|
||||
pos: [f64; 3],
|
||||
/// Distant light: direction towards the light.
|
||||
to_light: [f64; 3],
|
||||
ambient: f32,
|
||||
kd: f32,
|
||||
ks: f32,
|
||||
sharp: f32,
|
||||
}
|
||||
|
||||
impl Lighting {
|
||||
pub fn from(ctx: &EffectCtx, b: &Buf) -> Lighting {
|
||||
let pr = ctx.params;
|
||||
let f = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d);
|
||||
let [lw, lh] = ctx.layer_size;
|
||||
let s = b.scale;
|
||||
let centre = [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s, 0.0];
|
||||
let intensity = f("lighting/lightIntensity", 1.0) as f32;
|
||||
let lc = pr.get("lighting/lightColor").map(|v| v.as_color()).unwrap_or([1.0; 4]);
|
||||
let mut color = [lc[0] * intensity, lc[1] * intensity, lc[2] * intensity];
|
||||
let lp = pr.get("lighting/lightPosition").map(|v| v.as_vec2()).unwrap_or([lw * 0.5, lh * 0.5]);
|
||||
let (x, y) = b.to_px(lp);
|
||||
let mut pos = [x, y, -f("lighting/lightDepth", 1.0) * lw.max(lh) * s];
|
||||
let mut kind = if pr.e("lighting/lightType") == 1 { 1 } else { 0 };
|
||||
let mut to_light = norm(sub(pos, centre));
|
||||
if pr.e("lighting/lightType") == 2 {
|
||||
match ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.light) {
|
||||
Some(l) => {
|
||||
color = [l.color[0] * intensity, l.color[1] * intensity, l.color[2] * intensity];
|
||||
pos = [l.pos[0] * s + b.offset[0], l.pos[1] * s + b.offset[1], l.pos[2] * s];
|
||||
kind = l.kind;
|
||||
to_light = norm([-l.dir[0], -l.dir[1], -l.dir[2]]);
|
||||
}
|
||||
// No light in the comp: ambient only.
|
||||
None => kind = 2,
|
||||
}
|
||||
}
|
||||
Lighting {
|
||||
kind,
|
||||
color,
|
||||
pos,
|
||||
to_light,
|
||||
ambient: f("lighting/ambientLight", 0.25) as f32,
|
||||
kd: f("material/diffuse", 0.75) as f32,
|
||||
ks: f("material/specular", 0.0) as f32,
|
||||
sharp: f("material/highlightSharpness", 15.0).max(1.0) as f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Multiplier and additive highlight for a piece with unit normal `n` (rotated front normal)
|
||||
/// at `at`, seen from `eye`. Pieces are lit on whichever side faces the viewer.
|
||||
pub fn shade(&self, n: [f64; 3], at: [f64; 3], eye: [f64; 3]) -> ([f32; 3], [f32; 3]) {
|
||||
let v = norm(sub(eye, at));
|
||||
let n = if dot(n, v) < 0.0 { [-n[0], -n[1], -n[2]] } else { n };
|
||||
if self.kind == 2 {
|
||||
let a = self.ambient;
|
||||
return ([a + self.kd * self.color[0], a + self.kd * self.color[1], a + self.kd * self.color[2]], [0.0; 3]);
|
||||
}
|
||||
let l = if self.kind == 1 { norm(sub(self.pos, at)) } else { self.to_light };
|
||||
let ndl = dot(n, l).max(0.0) as f32;
|
||||
let h = norm([l[0] + v[0], l[1] + v[1], l[2] + v[2]]);
|
||||
let spec = if self.ks > 0.0 && ndl > 0.0 { self.ks * (dot(n, h).max(0.0) as f32).powf(self.sharp) } else { 0.0 };
|
||||
(std::array::from_fn(|k| self.ambient + self.kd * ndl * self.color[k]), std::array::from_fn(|k| spec * self.color[k]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn simple_camera_keeps_the_layer_plane() {
|
||||
let p = Proj::simple([10.0, 20.0], 100.0);
|
||||
assert_eq!(p.project([3.0, 4.0, 0.0]), Some([3.0, 4.0]));
|
||||
let e = p.eye();
|
||||
assert!((e[0] - 10.0).abs() < 1e-9 && (e[1] - 20.0).abs() < 1e-9 && (e[2] + 100.0).abs() < 1e-9);
|
||||
// Further away looks smaller (towards the centre).
|
||||
let q = p.project([30.0, 20.0, 100.0]).unwrap();
|
||||
assert!((q[0] - 20.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_orders_differ() {
|
||||
let a = rot_ordered(0.5, 0.3, 0.2, 0);
|
||||
let b = rot_ordered(0.5, 0.3, 0.2, 5);
|
||||
assert!((0..3).any(|i| (0..3).any(|j| (a[i][j] - b[i][j]).abs() > 1e-3)));
|
||||
let z = rot_ordered(0.0, 0.0, 0.0, 3);
|
||||
assert_eq!(z, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,51 +7,105 @@ use crate::{CATEGORIES, registry};
|
|||
/// Effects whose implementation is known to fall short of the reference behaviour:
|
||||
/// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far
|
||||
/// as the public behaviour documentation describes it.
|
||||
pub const PARTIAL: &[(&str, &str)] = &[
|
||||
("ec.blur.cameralens", "no Diffraction Fringe or Blur Map layer"),
|
||||
("ec.channel.minimax", "no Don't Shrink Edges option"),
|
||||
("ec.channel.combiner", "no Saturation Multiplied target"),
|
||||
("ec.3d.channelextract", "no anti-alias option"),
|
||||
("ec.color.huesaturation", "no per-range Channel Control"),
|
||||
("ec.color.levels", "master channel only (no Channel popup)"),
|
||||
("ec.color.curves", "no Channel popup"),
|
||||
("ec.color.colorama", "no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking"),
|
||||
("ec.color.lumetri", "no HSL Secondary, hue/saturation curves, HDR mode or look files"),
|
||||
("ec.color.selectivecolor", "simplified Colors / Details layout"),
|
||||
("ec.distort.turbulentdisplace", "no locked pinning variants"),
|
||||
("ec.distort.opticscompensation", "no Optimal Pixels"),
|
||||
("ec.distort.transform", "no shutter-angle motion blur"),
|
||||
("ec.distort.reshape", "no correspondence points"),
|
||||
("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"),
|
||||
("ec.generate.fill", "no Fill Mask / All Masks / feather"),
|
||||
("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"),
|
||||
("ec.generate.radiowaves", "no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile"),
|
||||
("ec.generate.scribble", "no caps, joins or Start/End Apply To"),
|
||||
("ec.generate.cellpattern", "HQ variants render like the standard ones"),
|
||||
("ec.key.innerouter", "one additional mask per side; no cleanup strokes"),
|
||||
("ec.key.advancedspill", "no Tolerance, Desaturate or Spill Color Correction"),
|
||||
("ec.keying.keylight", "no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing"),
|
||||
("ec.matte.refinesoft", "no Reduce Chatter or motion blur"),
|
||||
("ec.matte.refinehard", "no Reduce Chatter or motion blur"),
|
||||
("ec.obsolete.pathtext", "no kerning, shear/scale, line spacing, fade time or jitter"),
|
||||
("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"),
|
||||
("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"),
|
||||
("ec.noise.removegrain", "no preview region, sampling or temporal filtering"),
|
||||
("ec.vr.digitalglitch", "reduced control set"),
|
||||
("ec.vr.converter", "common layouts only"),
|
||||
("ec.time.timewarp", "Pixel Motion blends frames (no optical flow); no matte, warp or crop controls"),
|
||||
("ec.sim.carddance", "no camera system, corner pins or lighting/material controls"),
|
||||
("ec.sim.shatter", "no custom shatter map, gradient, textures or extrusion rendering"),
|
||||
("ec.sim.caustics", "no Sky group or light type"),
|
||||
("ec.sim.foam", "no wobble, pop velocity, custom bubble texture, environment map or flow map"),
|
||||
("ec.sim.particleplayground", "no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles"),
|
||||
("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"),
|
||||
("ec.stylize.glow", "no Glow Operation or arbitrary colour map"),
|
||||
("ec.stylize.cartoon", "no Edge Enhancement"),
|
||||
("ec.transition.cardwipe", "2D flip only; no back layer picker, camera, lighting or jitter"),
|
||||
("ec.transition.blockdissolve", "no Soft Edges option"),
|
||||
pub const PARTIAL: &[(&str, &str)] = &[];
|
||||
|
||||
/// Whether Effect Controls shows parameter (or twirl-down group) `param` (spec id path,
|
||||
/// `group/param`) of an instance of effect `effect`, given the instance's current values
|
||||
/// (`value(spec id)`). After Effects shows only the controls a popup selects: Levels' channel,
|
||||
/// Hue/Saturation's Channel Control, the camera system of the card effects, and so on. Every
|
||||
/// parameter stays animatable and addressable; this is presentation only.
|
||||
pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option<effectcraft_keyframe::Value>) -> bool {
|
||||
let e = |id: &str| value(id).map(|v| v.as_enum()).unwrap_or(0);
|
||||
let b = |id: &str| value(id).is_some_and(|v| v.as_bool());
|
||||
match effect {
|
||||
"ec.color.levels" => {
|
||||
let ch = e("channel") as usize;
|
||||
for (i, pre) in crate::color_fx::LEVELS_CHANNEL_PREFIX.iter().enumerate() {
|
||||
if param.starts_with(pre) {
|
||||
return ch == i + 1;
|
||||
}
|
||||
}
|
||||
let master = ["inBlack", "inWhite", "gamma", "outBlack", "outWhite", "clipToOutputBlack", "clipToOutputWhite"];
|
||||
!master.contains(¶m) || ch == 0
|
||||
}
|
||||
"ec.color.huesaturation" => {
|
||||
let ch = e("channelControl") as usize;
|
||||
for (i, (pre, _, _)) in crate::color_fx::HUESAT_RANGES.iter().enumerate() {
|
||||
if param.starts_with(pre) {
|
||||
return ch == i + 1;
|
||||
}
|
||||
}
|
||||
match param {
|
||||
"hue" | "saturation" | "lightness" => ch == 0,
|
||||
"colorizeSaturation" | "colorizeLightness" => b("colorize"),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
"ec.color.selectivecolor" => match param.strip_prefix("details/") {
|
||||
Some(rest) => {
|
||||
let g = rest.split('/').next().unwrap_or("");
|
||||
crate::color2::SC_GROUPS.iter().position(|x| *x == g).is_none_or(|i| i == e("colors") as usize)
|
||||
}
|
||||
None => true,
|
||||
},
|
||||
"ec.sim.carddance" | "ec.transition.cardwipe" => crate::card3d::shown(param, &e).unwrap_or(true),
|
||||
"ec.sim.shatter" => match param {
|
||||
"shape/customShatterMap" | "shape/whiteTilesFixed" => e("shape/pattern") == 5,
|
||||
_ => crate::card3d::shown(param, &e).unwrap_or(true),
|
||||
},
|
||||
_ => {
|
||||
let _ = b("");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls of GPU-accelerated effects that only the CPU implements: while any of them is away
|
||||
/// from its default the effect renders on the CPU. (Prefixes ending in `/` cover a whole group.)
|
||||
const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"ec.color.lumetri",
|
||||
&["highDynamicRange", "basicCorrection/inputLut", "creative/look", "curves/rgbCurves/", "curves/hueSaturationCurves/", "hslSecondary/"],
|
||||
),
|
||||
(
|
||||
"ec.color.colorama",
|
||||
&["inputPhase/addPhase", "outputCycle/usePresetPalette", "modify/", "pixelSelection/matchingMode", "masking/maskingMode", "masking/compositeOverLayer"],
|
||||
),
|
||||
(
|
||||
"ec.keying.keylight",
|
||||
&[
|
||||
"foregroundColourCorrection/colourSuppression",
|
||||
"foregroundColourCorrection/suppressionAmount",
|
||||
"foregroundColourCorrection/colourBalanceSaturation",
|
||||
"edgeColourCorrection/edgeColourSuppression",
|
||||
"edgeColourCorrection/edgeSuppressionAmount",
|
||||
"edgeColourCorrection/edgeColourBalanceSaturation",
|
||||
"sourceCrops/xMethod",
|
||||
"sourceCrops/yMethod",
|
||||
"sourceCrops/edgeColourAlpha",
|
||||
],
|
||||
),
|
||||
("ec.noise.addgrain", &["viewingMode", "preset", "color/redBalance", "color/greenBalance", "color/blueBalance", "previewRegion/"]),
|
||||
("ec.blur.cameralens", &["irisProperties/diffractionFringe", "blurMap/blurMapLayer"]),
|
||||
];
|
||||
|
||||
/// Whether the GPU kernel of effect `id` covers the instance's current settings (see
|
||||
/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants and Turbulent Displace's locked
|
||||
/// pinning).
|
||||
pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool {
|
||||
match id {
|
||||
"ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false,
|
||||
"ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false,
|
||||
_ => {}
|
||||
}
|
||||
let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true };
|
||||
let Some(spec) = crate::find(id) else { return true };
|
||||
spec.params
|
||||
.iter()
|
||||
.filter(|ps| controls.iter().any(|c| if c.ends_with('/') { ps.id.starts_with(c) } else { ps.id == *c }))
|
||||
.all(|ps| ctx.params.get(ps.id).is_none_or(|v| *v == crate::default_value(ps, ctx.layer_size)))
|
||||
}
|
||||
|
||||
/// Implementation status of effect `id` (`Implemented` or `Partial: …`).
|
||||
pub fn status(id: &str) -> String {
|
||||
match PARTIAL.iter().find(|(e, _)| *e == id) {
|
||||
|
|
@ -122,6 +176,60 @@ mod tests {
|
|||
assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_only_controls_keep_effects_off_the_gpu() {
|
||||
use effectcraft_keyframe::Value;
|
||||
let ctx_with = |id: &str, set: &[(&str, Value)]| {
|
||||
let s = crate::find(id).unwrap();
|
||||
let mut values: std::collections::HashMap<String, Value> =
|
||||
s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [64.0, 64.0]))).collect();
|
||||
for (k, v) in set {
|
||||
values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
crate::Params { values }
|
||||
};
|
||||
let ok = |id: &str, set: &[(&str, Value)]| {
|
||||
let params = ctx_with(id, set);
|
||||
let ctx = crate::EffectCtx { params: ¶ms, time: 0.0, layer_size: [64.0, 64.0], seed: 0, adjustment: false, env: Default::default() };
|
||||
super::gpu_supported(id, &ctx)
|
||||
};
|
||||
assert!(ok("ec.color.lumetri", &[]));
|
||||
assert!(!ok("ec.color.lumetri", &[("hslSecondary/correction/saturation", Value::Scalar(50.0))]));
|
||||
assert!(!ok("ec.noise.addgrain", &[("preset", Value::Enum(2))]));
|
||||
assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))]));
|
||||
assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))]));
|
||||
assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))]));
|
||||
assert!(ok("ec.blur.gaussian", &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popups_choose_the_shown_controls() {
|
||||
use effectcraft_keyframe::Value;
|
||||
let with = |pairs: &'static [(&'static str, u32)]| move |id: &str| pairs.iter().find(|(k, _)| *k == id).map(|(_, v)| Value::Enum(*v));
|
||||
let shown = |e: &str, p: &str, pairs: &'static [(&'static str, u32)]| super::param_shown(e, p, &with(pairs));
|
||||
// Levels: RGB shows the master controls only; Red shows the red ones.
|
||||
assert!(shown("ec.color.levels", "inBlack", &[]));
|
||||
assert!(!shown("ec.color.levels", "redInBlack", &[]));
|
||||
assert!(shown("ec.color.levels", "redInBlack", &[("channel", 1)]));
|
||||
assert!(!shown("ec.color.levels", "inBlack", &[("channel", 1)]));
|
||||
assert!(shown("ec.color.levels", "channel", &[("channel", 1)]));
|
||||
// Hue/Saturation: Master or one colour range.
|
||||
assert!(shown("ec.color.huesaturation", "hue", &[]));
|
||||
assert!(shown("ec.color.huesaturation", "bluesHue", &[("channelControl", 5)]));
|
||||
assert!(!shown("ec.color.huesaturation", "redsHue", &[("channelControl", 5)]));
|
||||
// Card effects: the camera system's group.
|
||||
assert!(shown("ec.sim.carddance", "cameraPosition", &[]));
|
||||
assert!(!shown("ec.sim.carddance", "cornerPins", &[]));
|
||||
assert!(shown("ec.transition.cardwipe", "cornerPins/upperLeftCorner", &[("cameraSystem", 1)]));
|
||||
assert!(!shown("ec.sim.shatter", "shape/customShatterMap", &[]));
|
||||
// Selective Color: the chosen range's Details group.
|
||||
assert!(shown("ec.color.selectivecolor", "details/reds", &[]));
|
||||
assert!(!shown("ec.color.selectivecolor", "details/blues/bluesCyan", &[]));
|
||||
assert!(shown("ec.color.selectivecolor", "details", &[]));
|
||||
// Everything else is always shown.
|
||||
assert!(shown("ec.blur.gaussian", "blurriness", &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_notes_name_registered_effects() {
|
||||
for (id, note) in super::PARTIAL {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ const COMBINER_FROM: [&str; 14] = [
|
|||
"Min RGB",
|
||||
];
|
||||
/// Channel Combiner's To options (a single value goes into one channel).
|
||||
const COMBINER_TO: [&str; 8] = ["Red Only", "Green Only", "Blue Only", "Alpha Only", "Lightness Only", "Hue Only", "Saturation Only", "Grayscale"];
|
||||
const COMBINER_TO: [&str; 9] =
|
||||
["Red Only", "Green Only", "Blue Only", "Alpha Only", "Lightness Only", "Hue Only", "Saturation Only", "Grayscale", "Saturation Multiplied"];
|
||||
|
||||
fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let from = ctx.params.e("from");
|
||||
|
|
@ -160,6 +161,13 @@ fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let (r, g, bb) = hsl_to_rgb(0.0, v.clamp(0.0, 1.0), 0.5);
|
||||
[r, g, bb]
|
||||
}
|
||||
// Saturation Multiplied: the pixel keeps its hue and lightness; its saturation
|
||||
// is scaled by the value.
|
||||
8 => {
|
||||
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
|
||||
let (r, g, bb) = hsl_to_rgb(h, (s * v).clamp(0.0, 1.0), l);
|
||||
[r, g, bb]
|
||||
}
|
||||
_ => [v, v, v],
|
||||
}
|
||||
}
|
||||
|
|
@ -175,6 +183,25 @@ fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
/// Minimax's Direction options.
|
||||
pub(crate) const MINIMAX_DIRECTIONS: [&str; 3] = ["Horizontal & Vertical", "Just Horizontal", "Just Vertical"];
|
||||
|
||||
/// `p` with `rx` / `ry` zero pixels added on every side.
|
||||
fn pad_plane(p: &Plane, rx: usize, ry: usize) -> Plane {
|
||||
let (w, h) = (p.w + 2 * rx, p.h + 2 * ry);
|
||||
let mut out = Plane::new(w, h);
|
||||
for y in 0..p.h {
|
||||
out.data[(y + ry) * w + rx..(y + ry) * w + rx + p.w].copy_from_slice(&p.data[y * p.w..(y + 1) * p.w]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `w`×`h` interior of a plane padded by [`pad_plane`].
|
||||
fn crop_plane(p: &Plane, rx: usize, ry: usize, w: usize, h: usize) -> Plane {
|
||||
let mut out = Plane::new(w, h);
|
||||
for y in 0..h {
|
||||
out.data[y * w..(y + 1) * w].copy_from_slice(&p.data[(y + ry) * p.w + rx..(y + ry) * p.w + rx + w]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn minimax(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize;
|
||||
if r == 0 {
|
||||
|
|
@ -182,6 +209,7 @@ fn minimax(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
}
|
||||
let op = ctx.params.e("operation");
|
||||
let ch = ctx.params.e("channel");
|
||||
let dont_shrink = ctx.params.b("dontShrinkEdges");
|
||||
let (rx, ry) = match ctx.params.e("direction") {
|
||||
1 => (r, 0),
|
||||
2 => (0, r),
|
||||
|
|
@ -204,12 +232,16 @@ fn minimax(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
if !sel[k] {
|
||||
continue;
|
||||
}
|
||||
*pl = match op {
|
||||
0 => morph_plane(pl, rx, ry, false),
|
||||
1 => morph_plane(pl, rx, ry, true),
|
||||
2 => morph_plane(&morph_plane(pl, rx, ry, false), rx, ry, true),
|
||||
_ => morph_plane(&morph_plane(pl, rx, ry, true), rx, ry, false),
|
||||
// Beyond the layer edge is empty (0), so Minimum eats into the edges unless Don't
|
||||
// Shrink Edges extends the edge pixels instead.
|
||||
let padded = if dont_shrink { pl.clone() } else { pad_plane(pl, rx, ry) };
|
||||
let out = match op {
|
||||
0 => morph_plane(&padded, rx, ry, false),
|
||||
1 => morph_plane(&padded, rx, ry, true),
|
||||
2 => morph_plane(&morph_plane(&padded, rx, ry, false), rx, ry, true),
|
||||
_ => morph_plane(&morph_plane(&padded, rx, ry, true), rx, ry, false),
|
||||
};
|
||||
*pl = if dont_shrink { out } else { crop_plane(&out, rx, ry, pl.w, pl.h) };
|
||||
}
|
||||
let mut img = join(&planes);
|
||||
img.data.par_iter_mut().for_each(|px| {
|
||||
|
|
@ -408,6 +440,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("radius", "Radius", num(0.0), slider(0.0, 500.0, 0.0, 100.0, 0)),
|
||||
p("channel", "Channel", Value::Enum(0), popup(&["Color", "Alpha", "Alpha and Color", "Red", "Green", "Blue"])),
|
||||
p("direction", "Direction", Value::Enum(0), popup(&MINIMAX_DIRECTIONS)),
|
||||
p("dontShrinkEdges", "Don't Shrink Edges", Value::Bool(false), ParamUi::Checkbox),
|
||||
],
|
||||
minimax,
|
||||
),
|
||||
|
|
@ -658,4 +691,23 @@ mod tests {
|
|||
assert!(close(out.get(0, 0), [0.0, 0.0, 1.0, 1.0]));
|
||||
assert!(close(out.get(1, 0), [1.0, 0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimax_shrinks_layer_edges_unless_told_not_to() {
|
||||
let img = Image::filled(9, 9, [1.0, 1.0, 1.0, 1.0]);
|
||||
let shrunk = run("ec.channel.minimax", &[("radius", num(2.0)), ("operation", Value::Enum(0))], img.clone());
|
||||
assert_eq!(shrunk.get(0, 4)[0], 0.0);
|
||||
assert_eq!(shrunk.get(4, 4)[0], 1.0);
|
||||
let kept = run("ec.channel.minimax", &[("radius", num(2.0)), ("operation", Value::Enum(0)), ("dontShrinkEdges", Value::Bool(true))], img);
|
||||
assert_eq!(kept.get(0, 4)[0], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_combiner_saturation_multiplied() {
|
||||
// From Lightness (0.5) into Saturation Multiplied halves the saturation, keeping hue.
|
||||
let red = Image::filled(1, 1, [0.75, 0.25, 0.25, 1.0]);
|
||||
let out = run("ec.channel.combiner", &[("from", Value::Enum(8)), ("to", Value::Enum(8))], red).get(0, 0);
|
||||
let (h, s, l) = rgb_to_hsl(out[0], out[1], out[2]);
|
||||
assert!((h.abs() < 1e-4 || (h - 1.0).abs() < 1e-4) && (s - 0.25).abs() < 1e-3 && (l - 0.5).abs() < 1e-3, "{h} {s} {l}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,12 +31,17 @@ fn aux(ctx: &EffectCtx) -> Option<Arc<AuxChannels>> {
|
|||
|
||||
/// Aux index for each buffer pixel (None outside the aux image).
|
||||
fn index_map(b: &Buf, a: &AuxChannels) -> Vec<Option<usize>> {
|
||||
index_map_at(b, a, [0.5, 0.5])
|
||||
}
|
||||
|
||||
/// [`index_map`] sampling each pixel at sub-pixel position `at` (0..1).
|
||||
fn index_map_at(b: &Buf, a: &AuxChannels, at: [f64; 2]) -> Vec<Option<usize>> {
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let inv = 1.0 / b.scale.max(1e-9);
|
||||
(0..w * h)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
let (x, y) = ((i % w) as f64 + at[0], (i / w) as f64 + at[1]);
|
||||
a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv)
|
||||
})
|
||||
.collect()
|
||||
|
|
@ -60,7 +65,28 @@ pub const EXTRACT_CHANNELS: [&str; 8] = ["Z-Depth", "Object ID", "Texture UV", "
|
|||
|
||||
fn channel_extract(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let Some(a) = aux(ctx) else { return b };
|
||||
if ctx.params.b("antialias") {
|
||||
// Anti-alias: average four sub-pixel samples of the channel.
|
||||
let subs = [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]];
|
||||
let imgs: Vec<Image> = subs.iter().map(|s| channel_extract_at(ctx, &b, &a, &index_map_at(&b, &a, *s))).collect::<Option<Vec<_>>>().unwrap_or_default();
|
||||
if imgs.len() == 4 {
|
||||
let mut out = imgs[0].clone();
|
||||
out.data.par_iter_mut().enumerate().for_each(|(i, p)| {
|
||||
*p = std::array::from_fn(|c| imgs.iter().map(|m| m.data[i][c]).sum::<f32>() * 0.25);
|
||||
});
|
||||
b.img = out;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
let idx = index_map(&b, &a);
|
||||
if let Some(i) = channel_extract_at(ctx, &b, &a, &idx) {
|
||||
b.img = i;
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// 3D Channel Extract through one index map (None when the channel is missing).
|
||||
fn channel_extract_at(ctx: &EffectCtx, b: &Buf, a: &AuxChannels, idx: &[Option<usize>]) -> Option<Image> {
|
||||
let (bp, wp) = (ctx.params.f("blackPoint") as f32, ctx.params.f("whitePoint") as f32);
|
||||
// Clamp Output limits the mapped values to 0..1; Invert Depth Map flips them.
|
||||
let (clamp, invert) = (ctx.params.b("clampOutput"), ctx.params.b("invertDepthMap"));
|
||||
|
|
@ -69,24 +95,20 @@ fn channel_extract(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let lv = |v: f32| if wp >= bp { lv(v) } else { 1.0 - cl((v - wp) / (bp - wp).max(1e-9)) };
|
||||
let lv = |v: f32| if invert { 1.0 - lv(v) } else { lv(v) };
|
||||
let get3 = |names: [&[&str]; 3]| -> Option<[&[f32]; 3]> { Some([a.find(names[0])?, a.find(names[1])?, a.find(names[2])?]) };
|
||||
let img: Option<Image> = match ctx.params.e("channel") {
|
||||
0 => a.depth().map(|z| map_grey(&b, &idx, |i| lv(z[i]))),
|
||||
1 => a.object_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))),
|
||||
7 => a.material_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))),
|
||||
4 => a.coverage().map(|c| map_grey(&b, &idx, |i| lv(c[i]))),
|
||||
match ctx.params.e("channel") {
|
||||
0 => a.depth().map(|z| map_grey(b, idx, |i| lv(z[i]))),
|
||||
1 => a.object_id().map(|c| map_grey(b, idx, |i| lv(c[i]))),
|
||||
7 => a.material_id().map(|c| map_grey(b, idx, |i| lv(c[i]))),
|
||||
4 => a.coverage().map(|c| map_grey(b, idx, |i| lv(c[i]))),
|
||||
2 => get3([&["UV.U", "U", "uv.x"], &["UV.V", "V", "uv.y"], &["UV.W", "W", "uv.z"]])
|
||||
.or_else(|| Some([a.find(&["UV.U", "U", "uv.x"])?, a.find(&["UV.V", "V", "uv.y"])?, a.find(&["UV.V", "V", "uv.y"])?]))
|
||||
.map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), 0.0])),
|
||||
.map(|c| map_rgb(b, idx, |i| [lv(c[0][i]), lv(c[1][i]), 0.0])),
|
||||
3 => get3([&["N.X", "Normal.X", "normals.x"], &["N.Y", "Normal.Y", "normals.y"], &["N.Z", "Normal.Z", "normals.z"]])
|
||||
.map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i] * 0.5 + 0.5), lv(c[1][i] * 0.5 + 0.5), lv(c[2][i] * 0.5 + 0.5)])),
|
||||
.map(|c| map_rgb(b, idx, |i| [lv(c[0][i] * 0.5 + 0.5), lv(c[1][i] * 0.5 + 0.5), lv(c[2][i] * 0.5 + 0.5)])),
|
||||
5 => get3([&["BG.R", "Background.R"], &["BG.G", "Background.G"], &["BG.B", "Background.B"]])
|
||||
.map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), lv(c[2][i])])),
|
||||
_ => get3([&["R"], &["G"], &["B"]]).map(|c| map_rgb(&b, &idx, |i| [c[0][i], c[1][i], c[2][i]])),
|
||||
};
|
||||
if let Some(i) = img {
|
||||
b.img = i;
|
||||
.map(|c| map_rgb(b, idx, |i| [lv(c[0][i]), lv(c[1][i]), lv(c[2][i])])),
|
||||
_ => get3([&["R"], &["G"], &["B"]]).map(|c| map_rgb(b, idx, |i| [c[0][i], c[1][i], c[2][i]])),
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn map_grey(b: &Buf, idx: &[Option<usize>], f: impl Fn(usize) -> f32 + Sync) -> Image {
|
||||
|
|
@ -443,6 +465,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("whitePoint", "White Point", num(10000.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)),
|
||||
p("clampOutput", "Clamp Output", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("invertDepthMap", "Invert Depth Map", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("antialias", "Anti-alias", Value::Bool(false), ParamUi::Checkbox),
|
||||
],
|
||||
channel_extract,
|
||||
),
|
||||
|
|
@ -689,4 +712,18 @@ mod tests {
|
|||
let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default());
|
||||
assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_extract_antialias_averages_sub_pixels() {
|
||||
// Depth at twice the layer resolution, alternating 0 / 1000 per aux column.
|
||||
let mut a = AuxChannels::new(40, 20, 2.0);
|
||||
a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())];
|
||||
let host = AuxHost(Arc::new(a));
|
||||
let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))];
|
||||
let hard = run("ec.3d.channelextract", &vals, &host);
|
||||
let v = hard.get(5, 5)[0];
|
||||
assert!(v == 0.0 || v == 1.0, "{v}");
|
||||
let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host);
|
||||
assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -504,6 +504,16 @@ const SC_IDS: [[&str; 4]; 9] = [
|
|||
["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"],
|
||||
["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"],
|
||||
];
|
||||
/// Selective Color's Colors popup (the range whose controls Effect Controls shows) and the
|
||||
/// Details twirl-down group of each range.
|
||||
pub const SC_COLORS: [&str; 9] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas", "Whites", "Neutrals", "Blacks"];
|
||||
pub const SC_GROUPS: [&str; 9] = ["reds", "yellows", "greens", "cyans", "blues", "magentas", "whites", "neutrals", "blacks"];
|
||||
|
||||
/// Spec id of range `r`'s control `leaf` (`details/reds/redsCyan`).
|
||||
fn sc_param_id(r: usize, leaf: &str) -> String {
|
||||
format!("details/{}/{leaf}", SC_GROUPS[r])
|
||||
}
|
||||
|
||||
const SC_NAMES: [[&str; 4]; 9] = [
|
||||
["Reds Cyan", "Reds Magenta", "Reds Yellow", "Reds Black"],
|
||||
["Yellows Cyan", "Yellows Magenta", "Yellows Yellow", "Yellows Black"],
|
||||
|
|
@ -534,7 +544,7 @@ fn sc_weights(c: [f32; 3]) -> [f32; 9] {
|
|||
}
|
||||
|
||||
fn selective_color(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let adj = SC_IDS.map(|ids| ids.map(|id| ctx.params.f(id) as f32 / 100.0));
|
||||
let adj: [[f32; 4]; 9] = std::array::from_fn(|r| SC_IDS[r].map(|id| ctx.params.f(&sc_param_id(r, id)) as f32 / 100.0));
|
||||
if adj.iter().all(|r| r.iter().all(|v| *v == 0.0)) {
|
||||
return b;
|
||||
}
|
||||
|
|
@ -882,10 +892,12 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
}
|
||||
lv_params.push(p("clipToOutputBlack", "Clip To Output Black", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP)));
|
||||
lv_params.push(p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP)));
|
||||
let mut sc_params = vec![p("method", "Method", Value::Enum(0), popup(&["Relative", "Absolute"]))];
|
||||
for (ids, names) in SC_IDS.iter().zip(SC_NAMES.iter()) {
|
||||
let mut sc_params = vec![p("method", "Method", Value::Enum(0), popup(&["Relative", "Absolute"])), p("colors", "Colors", Value::Enum(0), popup(&SC_COLORS))];
|
||||
// Details: every range's Cyan / Magenta / Yellow / Black in its own twirl-down.
|
||||
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
|
||||
for (r, (ids, names)) in SC_IDS.iter().zip(SC_NAMES.iter()).enumerate() {
|
||||
for k in 0..4 {
|
||||
sc_params.push(p(ids[k], names[k], num(0.0), pct()));
|
||||
sc_params.push(p(leak(sc_param_id(r, ids[k])), names[k], num(0.0), pct()));
|
||||
}
|
||||
}
|
||||
let clip_params = |extra: bool| {
|
||||
|
|
@ -911,7 +923,15 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
spec(
|
||||
"ec.color.curves",
|
||||
"Curves",
|
||||
vec![curve("rgb", "RGB"), curve("red", "Red"), curve("green", "Green"), curve("blue", "Blue"), curve("alpha", "Alpha")],
|
||||
vec![
|
||||
// The channel the graph edits (the Effect Controls graph's Channel popup).
|
||||
p("channel", "Channel", Value::Enum(0), ParamUi::Hidden),
|
||||
curve("rgb", "RGB"),
|
||||
curve("red", "Red"),
|
||||
curve("green", "Green"),
|
||||
curve("blue", "Blue"),
|
||||
curve("alpha", "Alpha"),
|
||||
],
|
||||
curves,
|
||||
),
|
||||
spec("ec.color.levelsic", "Levels (Individual Controls)", lv_params, levels_ic),
|
||||
|
|
@ -1138,10 +1158,10 @@ mod tests {
|
|||
let img = ramp();
|
||||
assert_eq!(run("ec.color.selectivecolor", &img, &[]), img);
|
||||
let red = Image::filled(2, 2, [1.0, 0.1, 0.1, 1.0]);
|
||||
let out = run("ec.color.selectivecolor", &red, &[("redsCyan", num(100.0)), ("method", Value::Enum(1))]);
|
||||
let out = run("ec.color.selectivecolor", &red, &[("details/reds/redsCyan", num(100.0)), ("method", Value::Enum(1))]);
|
||||
assert!(out.data[0][0] < 0.3, "{:?}", out.data[0]);
|
||||
let blue = Image::filled(2, 2, [0.1, 0.1, 1.0, 1.0]);
|
||||
assert_eq!(run("ec.color.selectivecolor", &blue, &[("redsCyan", num(100.0))]), blue);
|
||||
assert_eq!(run("ec.color.selectivecolor", &blue, &[("details/reds/redsCyan", num(100.0))]), blue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -249,8 +249,193 @@ fn tint_offset(c: [f32; 4]) -> [f32; 3] {
|
|||
[c[0] - l, c[1] - l, c[2] - l]
|
||||
}
|
||||
|
||||
/// Control points `x,y x,y …` (0..1, whitespace or `;` separated), sorted by x.
|
||||
fn parse_points(s: &str) -> Vec<(f32, f32)> {
|
||||
let mut pts: Vec<(f32, f32)> = s
|
||||
.split(|c: char| c.is_whitespace() || c == ';')
|
||||
.filter_map(|t| {
|
||||
let (x, y) = t.split_once(',')?;
|
||||
let (x, y) = (x.trim().parse::<f32>().ok()?, y.trim().parse::<f32>().ok()?);
|
||||
(x.is_finite() && y.is_finite()).then_some((x, y))
|
||||
})
|
||||
.collect();
|
||||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
|
||||
pts
|
||||
}
|
||||
|
||||
/// A Lumetri hue / saturation curve: control points `x,y` in 0..1 where y = 0.5 is neutral,
|
||||
/// interpolated with a cardinal (Catmull-Rom) spline through the points. Hue curves wrap
|
||||
/// around (x = 0 and x = 1 are the same hue); the others are flat beyond their end points.
|
||||
/// Tabulated as offsets `y − 0.5`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OffsetCurve {
|
||||
lut: Vec<f32>,
|
||||
}
|
||||
|
||||
impl OffsetCurve {
|
||||
const N: usize = 512;
|
||||
|
||||
/// `None` for an empty or flat-neutral curve.
|
||||
pub(crate) fn parse(s: &str, periodic: bool) -> Option<OffsetCurve> {
|
||||
let pts = parse_points(s);
|
||||
if pts.is_empty() || pts.iter().all(|p| (p.1 - 0.5).abs() < 1e-6) {
|
||||
return None;
|
||||
}
|
||||
let mut ext: Vec<(f32, f32)> = vec![];
|
||||
if periodic {
|
||||
let wrapped: Vec<(f32, f32)> = pts.iter().map(|&(x, y)| (x.rem_euclid(1.0), y)).collect();
|
||||
let mut w = wrapped;
|
||||
w.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
for k in -1..=1 {
|
||||
ext.extend(w.iter().map(|&(x, y)| (x + k as f32, y)));
|
||||
}
|
||||
} else {
|
||||
let (x0, y0) = pts[0];
|
||||
let (x1, y1) = pts[pts.len() - 1];
|
||||
ext.push((x0.min(0.0) - 2.0, y0));
|
||||
ext.push((x0.min(0.0) - 1.0, y0));
|
||||
ext.extend(pts.iter().copied());
|
||||
ext.push((x1.max(1.0) + 1.0, y1));
|
||||
ext.push((x1.max(1.0) + 2.0, y1));
|
||||
}
|
||||
let n = ext.len();
|
||||
// Tangents: the mean of the neighbouring slopes, flat at local extrema and next to flat
|
||||
// segments so the curve never overshoots its points.
|
||||
let slope = |k: usize| (ext[k + 1].1 - ext[k].1) / (ext[k + 1].0 - ext[k].0).max(1e-6);
|
||||
let m: Vec<f32> = (0..n)
|
||||
.map(|k| {
|
||||
if k == 0 || k == n - 1 {
|
||||
return 0.0;
|
||||
}
|
||||
let (a, b) = (slope(k - 1), slope(k));
|
||||
if a * b <= 0.0 { 0.0 } else { (a + b) * 0.5 }
|
||||
})
|
||||
.collect();
|
||||
let eval = |x: f32| -> f32 {
|
||||
let k = ext.partition_point(|p| p.0 <= x).saturating_sub(1).min(n - 2);
|
||||
let (xa, ya) = ext[k];
|
||||
let (xb, yb) = ext[k + 1];
|
||||
let h = (xb - xa).max(1e-6);
|
||||
let t = ((x - xa) / h).clamp(0.0, 1.0);
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
(2.0 * t3 - 3.0 * t2 + 1.0) * ya + (t3 - 2.0 * t2 + t) * h * m[k] + (-2.0 * t3 + 3.0 * t2) * yb + (t3 - t2) * h * m[k + 1]
|
||||
};
|
||||
let single = pts.len() == 1;
|
||||
let lut = (0..=Self::N).map(|i| if single { pts[0].1 - 0.5 } else { eval(i as f32 / Self::N as f32) - 0.5 }).collect();
|
||||
Some(OffsetCurve { lut })
|
||||
}
|
||||
|
||||
/// Offset from neutral at `x` (clamped to 0..1).
|
||||
pub(crate) fn at(&self, x: f32) -> f32 {
|
||||
let f = x.clamp(0.0, 1.0) * Self::N as f32;
|
||||
let i = (f as usize).min(Self::N - 1);
|
||||
let t = f - i as f32;
|
||||
self.lut[i] + (self.lut[i + 1] - self.lut[i]) * t
|
||||
}
|
||||
}
|
||||
|
||||
/// Lumetri's built-in creative looks (our own formulas; Custom loads a `.cube` file).
|
||||
pub const LUMETRI_LOOKS: [&str; 8] =
|
||||
["None", "Custom", "EC Warm Film", "EC Cool Night", "EC Bleach Bypass", "EC Teal & Orange", "EC Faded Matte", "EC Monochrome"];
|
||||
|
||||
fn builtin_look(look: u32, c: [f32; 3]) -> [f32; 3] {
|
||||
let l = luminance(c[0], c[1], c[2]);
|
||||
match look {
|
||||
2 => [c[0] * 1.06 + 0.015, c[1] * 1.01 + 0.01, c[2] * 0.88],
|
||||
3 => [c[0] * 0.82, c[1] * 0.92, c[2] * 1.08 + 0.02].map(|v| v * 0.92),
|
||||
4 => {
|
||||
// Silver retained: half the saturation, a luminance overlay for contrast.
|
||||
let d = c.map(|v| l + (v - l) * 0.45);
|
||||
let o = |v: f32| if l < 0.5 { 2.0 * v * l } else { 1.0 - 2.0 * (1.0 - v) * (1.0 - l) };
|
||||
d.map(|v| v * 0.5 + o(v) * 0.5)
|
||||
}
|
||||
5 => {
|
||||
let t = (l - 0.5).clamp(-0.5, 0.5);
|
||||
[c[0] + 0.18 * t, c[1] + 0.03 * t, c[2] - 0.16 * t]
|
||||
}
|
||||
6 => c.map(|v| 0.08 + v * 0.84).map(|v| l + (v - l) * 0.8),
|
||||
7 => [l; 3],
|
||||
_ => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// HSL Secondary's Show Mask styles.
|
||||
const HSL_MASK_STYLES: [&str; 3] = ["Color/Gray", "Color/Black", "White/Black"];
|
||||
|
||||
/// HSL Secondary key: membership (0..1) of an HSL colour (h, s, l in 0..1).
|
||||
struct HslKey {
|
||||
hue: Option<(f32, f32, f32)>,
|
||||
sat: Option<(f32, f32, f32)>,
|
||||
luma: Option<(f32, f32, f32)>,
|
||||
}
|
||||
|
||||
impl HslKey {
|
||||
fn from(ctx: &EffectCtx) -> HslKey {
|
||||
let pr = ctx.params;
|
||||
let k = |id: &str| pr.f(&format!("hslSecondary/key/{id}")) as f32;
|
||||
let on = |id: &str| pr.get(&format!("hslSecondary/key/{id}")).is_none_or(|v| v.as_bool());
|
||||
let band = |lo: &str, hi: &str, soft: &str| (k(lo) / 100.0, k(hi) / 100.0, k(soft) / 100.0);
|
||||
HslKey {
|
||||
hue: on("hueEnabled").then(|| (k("hueCenter"), k("hueRange"), k("hueSoftness"))),
|
||||
sat: on("saturationEnabled").then(|| band("saturationLow", "saturationHigh", "saturationSoftness")),
|
||||
luma: on("lightnessEnabled").then(|| band("lightnessLow", "lightnessHigh", "lightnessSoftness")),
|
||||
}
|
||||
}
|
||||
fn band(v: f32, (lo, hi, soft): (f32, f32, f32)) -> f32 {
|
||||
let (lo, hi) = (lo.min(hi), lo.max(hi));
|
||||
let d = if v < lo {
|
||||
lo - v
|
||||
} else if v > hi {
|
||||
v - hi
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if d <= 0.0 {
|
||||
1.0
|
||||
} else if soft > 0.0 {
|
||||
(1.0 - d / soft).max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
fn weight(&self, h: f32, s: f32, l: f32) -> f32 {
|
||||
let mut w = 1.0;
|
||||
if let Some((centre, range, soft)) = self.hue
|
||||
&& range < 180.0
|
||||
{
|
||||
let d = ((h * 360.0 - centre).rem_euclid(360.0)).min((centre - h * 360.0).rem_euclid(360.0));
|
||||
let hw = if d <= range {
|
||||
1.0
|
||||
} else if soft > 0.0 {
|
||||
(1.0 - (d - range) / soft).max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Greys have no hue: they belong to a hue key only as far as they are saturated.
|
||||
w *= hw * (s * 20.0).min(1.0);
|
||||
}
|
||||
if let Some(b) = self.sat {
|
||||
w *= Self::band(s, b);
|
||||
}
|
||||
if let Some(b) = self.luma {
|
||||
w *= Self::band(l, b);
|
||||
}
|
||||
w
|
||||
}
|
||||
}
|
||||
|
||||
/// Lumetri Color: Basic Correction (input LUT, white balance, HDR range, tone, saturation),
|
||||
/// Creative (look, adjustments), Curves (tone sliders, RGB curves, hue/saturation curves),
|
||||
/// Color Wheels, HSL Secondary (key, refine, correction), Vignette, in that order.
|
||||
fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let hdr = pr.b("highDynamicRange");
|
||||
let hdr_white = if hdr { (pr.get("basicCorrection/tone/hdrWhite").map(Value::as_f64).unwrap_or(100.0) / 100.0).max(0.01) as f32 } else { 1.0 };
|
||||
let hdr_spec = if hdr { (pr.f("basicCorrection/tone/hdrSpecular") / 100.0) as f32 } else { 0.0 };
|
||||
let input_lut = if pr.e("basicCorrection/inputLut") == 1 { crate::utility::load_lut(pr.s("basicCorrection/inputLutFile")) } else { None };
|
||||
let look = pr.e("creative/look");
|
||||
let look_lut = if look == 1 { crate::utility::load_lut(pr.s("creative/lookFile")) } else { None };
|
||||
let temp = (pr.f("basicCorrection/whiteBalance/temperature") / 100.0) as f32;
|
||||
let tint = (pr.f("basicCorrection/whiteBalance/tint") / 100.0) as f32;
|
||||
let expo = 2f32.powf(pr.f("basicCorrection/tone/exposure") as f32);
|
||||
|
|
@ -277,6 +462,12 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
]
|
||||
};
|
||||
let (cm, cr, cg, cb) = (curve("curveMaster"), curve("curveRed"), curve("curveGreen"), curve("curveBlue"));
|
||||
let rgb_curves =
|
||||
["curves/rgbCurves/master", "curves/rgbCurves/red", "curves/rgbCurves/green", "curves/rgbCurves/blue"].map(|id| crate::color2::Curve::parse(pr.s(id)));
|
||||
let hs = |id: &str, periodic: bool| OffsetCurve::parse(pr.s(&format!("curves/hueSaturationCurves/{id}")), periodic);
|
||||
let (hue_sat, hue_hue, hue_luma, luma_sat, sat_sat) =
|
||||
(hs("hueVsSat", true), hs("hueVsHue", true), hs("hueVsLuma", true), hs("lumaVsSat", false), hs("satVsSat", false));
|
||||
let any_hs = hue_sat.is_some() || hue_hue.is_some() || hue_luma.is_some() || luma_sat.is_some() || sat_sat.is_some();
|
||||
let ws = tint_offset(pr.color("colorWheels/shadowsWheel"));
|
||||
let wm = tint_offset(pr.color("colorWheels/midtonesWheel"));
|
||||
let wh_ = tint_offset(pr.color("colorWheels/highlightsWheel"));
|
||||
|
|
@ -285,12 +476,29 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let vround = (pr.f("vignette/vignetteRoundness") / 100.0) as f32;
|
||||
let vfeather = (pr.f("vignette/vignetteFeather") / 100.0) as f32;
|
||||
let sharpen = (pr.f("creative/adjustments/sharpen") / 100.0) as f32;
|
||||
// HSL Secondary.
|
||||
let sec = |id: &str, d: f64| pr.get(&format!("hslSecondary/{id}")).map(Value::as_f64).unwrap_or(d) as f32;
|
||||
let s_temp = sec("correction/temperature", 0.0) / 100.0;
|
||||
let s_tint = sec("correction/tint", 0.0) / 100.0;
|
||||
let s_contrast = sec("correction/contrast", 0.0) / 100.0;
|
||||
let s_sharpen = sec("correction/sharpen", 0.0) / 100.0;
|
||||
let s_sat = sec("correction/saturation", 100.0) / 100.0;
|
||||
let s_wheel = pr.get("hslSecondary/correction/correctionWheel").map(|v| tint_offset(v.as_color())).unwrap_or([0.0; 3]);
|
||||
let show_mask = pr.b("hslSecondary/key/showMask");
|
||||
let sec_active = show_mask || s_temp != 0.0 || s_tint != 0.0 || s_contrast != 0.0 || s_sharpen != 0.0 || s_sat != 1.0 || s_wheel != [0.0; 3];
|
||||
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
|
||||
let w = b.img.width as usize;
|
||||
let (cx, cy) = (lx + lw * 0.5, ly + lh * 0.5);
|
||||
map_colour(&mut b, |i, c| {
|
||||
map_colour(&mut b, |_, c| {
|
||||
let mut c = c;
|
||||
// Basic Correction: white balance, exposure, contrast, tone, saturation.
|
||||
if let Some(l) = &input_lut {
|
||||
c = l.apply(c);
|
||||
}
|
||||
// Basic Correction: white balance, exposure, contrast, tone, saturation. With High
|
||||
// Dynamic Range the tone controls work on a range whose white is HDR White.
|
||||
if hdr_white != 1.0 {
|
||||
c = c.map(|v| v / hdr_white);
|
||||
}
|
||||
if temp != 0.0 || tint != 0.0 {
|
||||
c = [c[0] * (1.0 + 0.25 * temp), c[1] * (1.0 - 0.25 * tint), c[2] * (1.0 - 0.25 * temp)];
|
||||
}
|
||||
|
|
@ -306,13 +514,24 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let d = 0.25 * hi * smoothstep(0.5, 1.0, lt) + 0.25 * sh * (1.0 - smoothstep(0.0, 0.5, lt)) + 0.2 * wh * lt * lt + 0.2 * bl * (1.0 - lt).powi(2);
|
||||
c = c.map(|v| v + d);
|
||||
}
|
||||
if hdr_spec != 0.0 {
|
||||
// HDR Specular scales what lies above white.
|
||||
c = c.map(|v| if v > 1.0 { 1.0 + (v - 1.0) * (1.0 + hdr_spec).max(0.0) } else { v });
|
||||
}
|
||||
if hdr_white != 1.0 {
|
||||
c = c.map(|v| v * hdr_white);
|
||||
}
|
||||
let l = luminance(c[0], c[1], c[2]);
|
||||
if sat != 1.0 {
|
||||
c = c.map(|v| l + (v - l) * sat);
|
||||
}
|
||||
// Creative.
|
||||
if intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || st != [0.0; 3] || ht != [0.0; 3]) {
|
||||
let mut d = c;
|
||||
// Creative: the look, then the adjustments, all at Intensity.
|
||||
let has_look = look >= 2 || look_lut.is_some();
|
||||
if intensity != 0.0 && (has_look || faded != 0.0 || vib != 0.0 || csat != 1.0 || st != [0.0; 3] || ht != [0.0; 3]) {
|
||||
let mut d = match &look_lut {
|
||||
Some(lut) => lut.apply(c),
|
||||
None => builtin_look(look, c),
|
||||
};
|
||||
if faded != 0.0 {
|
||||
d = d.map(|v| v * (1.0 - 0.25 * faded) + 0.12 * faded);
|
||||
}
|
||||
|
|
@ -336,9 +555,40 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
}
|
||||
c = [lerp(c[0], d[0], intensity), lerp(c[1], d[1], intensity), lerp(c[2], d[2], intensity)];
|
||||
}
|
||||
// Curves.
|
||||
// Curves: tone sliders, RGB curves (master, then each channel), hue/saturation curves.
|
||||
c = c.map(|v| tone_curve(v, cm[0], cm[1], cm[2]));
|
||||
c = [tone_curve(c[0], cr[0], cr[1], cr[2]), tone_curve(c[1], cg[0], cg[1], cg[2]), tone_curve(c[2], cb[0], cb[1], cb[2])];
|
||||
if let Some(m) = &rgb_curves[0] {
|
||||
c = c.map(|v| m.eval(v));
|
||||
}
|
||||
for k in 0..3 {
|
||||
if let Some(cv) = &rgb_curves[k + 1] {
|
||||
c[k] = cv.eval(c[k]);
|
||||
}
|
||||
}
|
||||
if any_hs {
|
||||
let (h, s, l) = rgb_to_hsl(c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0));
|
||||
let chroma = (s * 20.0).min(1.0);
|
||||
let off = |cv: &Option<OffsetCurve>, x: f32| cv.as_ref().map(|cv| cv.at(x)).unwrap_or(0.0);
|
||||
// y = 1 doubles saturation, y = 0 removes it.
|
||||
let mut ms = (1.0 + 2.0 * off(&hue_sat, h) * chroma) * (1.0 + 2.0 * off(&luma_sat, l)) * (1.0 + 2.0 * off(&sat_sat, s));
|
||||
ms = ms.max(0.0);
|
||||
let dh = off(&hue_hue, h) * chroma;
|
||||
let dl = off(&hue_luma, h) * chroma;
|
||||
let luma = luminance(c[0], c[1], c[2]);
|
||||
if dh != 0.0 {
|
||||
let (r, g, b2) = hsl_to_rgb((h + dh).rem_euclid(1.0), s, l);
|
||||
// Keep values outside 0..1 (HDR) by moving only the in-range part.
|
||||
let base = [c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0)];
|
||||
c = [c[0] + r - base[0], c[1] + g - base[1], c[2] + b2 - base[2]];
|
||||
}
|
||||
if ms != 1.0 {
|
||||
c = c.map(|v| luma + (v - luma) * ms);
|
||||
}
|
||||
if dl != 0.0 {
|
||||
c = c.map(|v| v + dl);
|
||||
}
|
||||
}
|
||||
// Color wheels.
|
||||
if ws != [0.0; 3] || wm != [0.0; 3] || wh_ != [0.0; 3] {
|
||||
let lt = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0);
|
||||
|
|
@ -349,6 +599,70 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
c[k] += 0.5 * (ws[k] * a + wm[k] * m + wh_[k] * z);
|
||||
}
|
||||
}
|
||||
c
|
||||
});
|
||||
// HSL Secondary: key the graded colour, refine the matte, apply the correction through it.
|
||||
let mask = sec_active.then(|| {
|
||||
let key = HslKey::from(ctx);
|
||||
let mut m = Plane::from_image(&b.img, |px| {
|
||||
let (c, a) = unpremul(px);
|
||||
if a <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (h, s, l) = rgb_to_hsl(c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0));
|
||||
key.weight(h, s, l)
|
||||
});
|
||||
let denoise = sec("refine/denoise", 0.0) / 100.0;
|
||||
if denoise > 0.0 {
|
||||
// Smooth speckle, then restore the matte's edge contrast.
|
||||
let s = (denoise * 3.0) as f64 * b.scale;
|
||||
m = gauss_plane(&m, s, s);
|
||||
let k = 1.0 + denoise * 2.0;
|
||||
m.data.iter_mut().for_each(|v| *v = ((*v - 0.5) * k + 0.5).clamp(0.0, 1.0));
|
||||
}
|
||||
let blur = sec("refine/blur", 0.0) / 100.0;
|
||||
if blur > 0.0 {
|
||||
let s = (blur * 10.0) as f64 * b.scale;
|
||||
m = gauss_plane(&m, s, s);
|
||||
}
|
||||
if pr.b("hslSecondary/key/invertMask") {
|
||||
m.data.iter_mut().for_each(|v| *v = 1.0 - *v);
|
||||
}
|
||||
m
|
||||
});
|
||||
let style = pr.e("hslSecondary/key/maskStyle");
|
||||
map_colour(&mut b, |i, c| {
|
||||
let mut c = c;
|
||||
if let Some(m) = &mask {
|
||||
let k = m.data[i];
|
||||
if show_mask {
|
||||
return match style {
|
||||
0 => {
|
||||
let g = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) * 0.2 + 0.4;
|
||||
[lerp(g, c[0], k), lerp(g, c[1], k), lerp(g, c[2], k)]
|
||||
}
|
||||
1 => c.map(|v| v * k),
|
||||
_ => [k; 3],
|
||||
};
|
||||
}
|
||||
if k > 0.0 {
|
||||
let mut d = c;
|
||||
if s_temp != 0.0 || s_tint != 0.0 {
|
||||
d = [d[0] * (1.0 + 0.25 * s_temp), d[1] * (1.0 - 0.25 * s_tint), d[2] * (1.0 - 0.25 * s_temp)];
|
||||
}
|
||||
if s_contrast != 0.0 {
|
||||
d = d.map(|v| 0.5 + (v - 0.5) * (1.0 + s_contrast));
|
||||
}
|
||||
let l = luminance(d[0], d[1], d[2]);
|
||||
if s_sat != 1.0 {
|
||||
d = d.map(|v| l + (v - l) * s_sat);
|
||||
}
|
||||
for j in 0..3 {
|
||||
d[j] += 0.5 * s_wheel[j];
|
||||
}
|
||||
c = [lerp(c[0], d[0], k), lerp(c[1], d[1], k), lerp(c[2], d[2], k)];
|
||||
}
|
||||
}
|
||||
// Vignette.
|
||||
if vig != 0.0 {
|
||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
|
|
@ -366,7 +680,8 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
}
|
||||
c.map(|v| v.max(0.0))
|
||||
});
|
||||
if sharpen != 0.0 {
|
||||
let sec_sharpen = if show_mask { 0.0 } else { s_sharpen };
|
||||
if sharpen != 0.0 || (sec_sharpen != 0.0 && mask.is_some()) {
|
||||
let luma = Plane::luma(&b.img);
|
||||
let blur = gauss_plane(&luma, 1.0 * b.scale, 1.0 * b.scale);
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
|
|
@ -374,7 +689,8 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
if a <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let d = (luma.data[i] - blur.data[i]) * sharpen * 2.0;
|
||||
let amount = sharpen + sec_sharpen * mask.as_ref().map(|m| m.data[i]).unwrap_or(0.0);
|
||||
let d = (luma.data[i] - blur.data[i]) * amount * 2.0;
|
||||
*px = premul(c.map(|v| (v + d).max(0.0)), a);
|
||||
});
|
||||
}
|
||||
|
|
@ -384,7 +700,10 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
pub fn specs() -> Vec<EffectSpec> {
|
||||
let grey = || col(0.5, 0.5, 0.5);
|
||||
let mut lum = vec![
|
||||
p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None"])),
|
||||
p("highDynamicRange", "High Dynamic Range", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None", "Custom"])),
|
||||
// A .cube file path (or the file's text) for Input LUT = Custom.
|
||||
p("basicCorrection/inputLutFile", "Input LUT File", Value::Str(String::new()), ParamUi::Text),
|
||||
p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), bipolar()),
|
||||
p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
|
||||
|
|
@ -393,8 +712,11 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("basicCorrection/tone/shadows", "Shadows", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/whites", "Whites", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/blacks", "Blacks", num(0.0), bipolar()),
|
||||
p("basicCorrection/tone/hdrWhite", "HDR White", num(100.0), slider(100.0, 10000.0, 100.0, 4000.0, 0)),
|
||||
p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), bipolar()),
|
||||
p("basicCorrection/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("creative/look", "Look", Value::Enum(0), popup(&["None"])),
|
||||
p("creative/look", "Look", Value::Enum(0), popup(&LUMETRI_LOOKS)),
|
||||
p("creative/lookFile", "Look File", Value::Str(String::new()), ParamUi::Text),
|
||||
p("creative/lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), pct()),
|
||||
p("creative/adjustments/sharpen", "Sharpen", num(0.0), bipolar()),
|
||||
|
|
@ -421,10 +743,50 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
for (id, name) in CURVES {
|
||||
lum.push(p(id, name, num(0.0), bipolar()));
|
||||
}
|
||||
// Curve graphs: control points "x,y x,y …" in 0..1 (like Curves). RGB curves default to
|
||||
// the identity diagonal; hue/saturation curves are flat at y = 0.5 (no change) and empty
|
||||
// means flat.
|
||||
let pts = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Hidden);
|
||||
lum.extend([
|
||||
pts("curves/rgbCurves/master", "RGB Curve", "0,0 1,1"),
|
||||
pts("curves/rgbCurves/red", "Red Curve", "0,0 1,1"),
|
||||
pts("curves/rgbCurves/green", "Green Curve", "0,0 1,1"),
|
||||
pts("curves/rgbCurves/blue", "Blue Curve", "0,0 1,1"),
|
||||
pts("curves/hueSaturationCurves/hueVsSat", "Hue vs Sat", ""),
|
||||
pts("curves/hueSaturationCurves/hueVsHue", "Hue vs Hue", ""),
|
||||
pts("curves/hueSaturationCurves/hueVsLuma", "Hue vs Luma", ""),
|
||||
pts("curves/hueSaturationCurves/lumaVsSat", "Luma vs Sat", ""),
|
||||
pts("curves/hueSaturationCurves/satVsSat", "Sat vs Sat", ""),
|
||||
]);
|
||||
lum.extend([
|
||||
p("colorWheels/shadowsWheel", "Shadows", grey(), ParamUi::Color),
|
||||
p("colorWheels/midtonesWheel", "Midtones", grey(), ParamUi::Color),
|
||||
p("colorWheels/highlightsWheel", "Highlights", grey(), ParamUi::Color),
|
||||
// HSL Secondary: key (hue centre / range / softness in degrees, saturation and
|
||||
// lightness bands in %), refine, correction.
|
||||
p("hslSecondary/key/hueEnabled", "H", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("hslSecondary/key/hueCenter", "Hue Center", num(0.0), ParamUi::Angle),
|
||||
p("hslSecondary/key/hueRange", "Hue Range", num(180.0), slider(0.0, 180.0, 0.0, 180.0, 1)),
|
||||
p("hslSecondary/key/hueSoftness", "Hue Softness", num(0.0), slider(0.0, 180.0, 0.0, 90.0, 1)),
|
||||
p("hslSecondary/key/saturationEnabled", "S", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("hslSecondary/key/saturationLow", "Saturation Low", num(0.0), pct()),
|
||||
p("hslSecondary/key/saturationHigh", "Saturation High", num(100.0), pct()),
|
||||
p("hslSecondary/key/saturationSoftness", "Saturation Softness", num(0.0), pct()),
|
||||
p("hslSecondary/key/lightnessEnabled", "L", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("hslSecondary/key/lightnessLow", "Lightness Low", num(0.0), pct()),
|
||||
p("hslSecondary/key/lightnessHigh", "Lightness High", num(100.0), pct()),
|
||||
p("hslSecondary/key/lightnessSoftness", "Lightness Softness", num(0.0), pct()),
|
||||
p("hslSecondary/key/showMask", "Show Mask", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("hslSecondary/key/maskStyle", "Mask Style", Value::Enum(0), popup(&HSL_MASK_STYLES)),
|
||||
p("hslSecondary/key/invertMask", "Invert Mask", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("hslSecondary/refine/denoise", "Denoise", num(0.0), pct()),
|
||||
p("hslSecondary/refine/blur", "Blur", num(0.0), pct()),
|
||||
p("hslSecondary/correction/correctionWheel", "Correction", grey(), ParamUi::Color),
|
||||
p("hslSecondary/correction/temperature", "Temperature", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/tint", "Tint", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/contrast", "Contrast", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/sharpen", "Sharpen", num(0.0), bipolar()),
|
||||
p("hslSecondary/correction/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("vignette/vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)),
|
||||
p("vignette/vignetteMidpoint", "Midpoint", num(50.0), pct()),
|
||||
p("vignette/vignetteRoundness", "Roundness", num(0.0), bipolar()),
|
||||
|
|
@ -580,4 +942,133 @@ mod tests {
|
|||
assert!(c.get(0, 0)[0] > 0.4);
|
||||
assert_eq!(c.data, run("ec.color.lumetri", &[("curves/curveMasterMidtones", num(50.0))], g).data);
|
||||
}
|
||||
|
||||
fn s(v: &str) -> Value {
|
||||
Value::Str(v.into())
|
||||
}
|
||||
|
||||
/// Red, green and grey swatches side by side.
|
||||
fn swatches() -> Image {
|
||||
let mut img = Image::new(3, 1);
|
||||
img.set(0, 0, [0.8, 0.1, 0.1, 1.0]);
|
||||
img.set(1, 0, [0.1, 0.8, 0.1, 1.0]);
|
||||
img.set(2, 0, [0.5, 0.5, 0.5, 1.0]);
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_rgb_curves_per_channel() {
|
||||
let g = Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]);
|
||||
let m = run("ec.color.lumetri", &[("curves/rgbCurves/master", s("0,0 0.5,0.75 1,1"))], g.clone()).get(0, 0);
|
||||
assert!((m[0] - 0.75).abs() < 1e-3 && (m[2] - 0.75).abs() < 1e-3);
|
||||
let r = run("ec.color.lumetri", &[("curves/rgbCurves/red", s("0,0 0.5,0.25 1,1"))], g).get(0, 0);
|
||||
assert!((r[0] - 0.25).abs() < 1e-3 && (r[1] - 0.5).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_hue_saturation_curves_target_hues() {
|
||||
let img = swatches();
|
||||
// Hue vs Sat: desaturate reds only (a dip to 0 at hue 0, neutral from 0.2 to 0.8).
|
||||
let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsSat", s("0,0 0.2,0.5 0.8,0.5"))], img.clone());
|
||||
let red = out.get(0, 0);
|
||||
assert!((red[0] - red[1]).abs() < 0.02, "red desaturated: {red:?}");
|
||||
assert!(close_px(out.get(1, 0), img.get(1, 0), 1e-3), "green untouched");
|
||||
assert!(close_px(out.get(2, 0), img.get(2, 0), 1e-4), "grey untouched");
|
||||
// Hue vs Hue: shift reds by +1/6 turn (y = 0.5 + 60/360) towards yellow.
|
||||
let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsHue", s("0,0.6667 0.2,0.5 0.8,0.5"))], img.clone());
|
||||
let p = out.get(0, 0);
|
||||
assert!(p[1] > 0.6 && p[0] > 0.6 && p[2] < 0.2, "red became yellow: {p:?}");
|
||||
// Hue vs Luma brightens greens only.
|
||||
let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsLuma", s("0.333,0.7 0.1,0.5 0.6,0.5"))], img.clone());
|
||||
assert!(out.get(1, 0)[1] > 0.85 && close_px(out.get(0, 0), img.get(0, 0), 1e-3));
|
||||
// Luma vs Sat / Sat vs Sat: y = 0 everywhere removes all saturation.
|
||||
for id in ["lumaVsSat", "satVsSat"] {
|
||||
let out = run("ec.color.lumetri", &[(&format!("curves/hueSaturationCurves/{id}"), s("0,0 1,0"))], img.clone());
|
||||
let p = out.get(0, 0);
|
||||
assert!((p[0] - p[1]).abs() < 1e-3 && (p[1] - p[2]).abs() < 1e-3, "{id}: {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn close_px(a: [f32; 4], b: [f32; 4], eps: f32) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_hsl_secondary_keys_and_corrects() {
|
||||
let img = swatches();
|
||||
let key_red = [("hslSecondary/key/hueCenter", num(0.0)), ("hslSecondary/key/hueRange", num(20.0)), ("hslSecondary/key/hueSoftness", num(10.0))];
|
||||
let mut vals: Vec<(&str, Value)> = key_red.to_vec();
|
||||
vals.push(("hslSecondary/correction/saturation", num(0.0)));
|
||||
let out = run("ec.color.lumetri", &vals, img.clone());
|
||||
let r = out.get(0, 0);
|
||||
assert!((r[0] - r[1]).abs() < 1e-3, "red desaturated through the key: {r:?}");
|
||||
assert!(close_px(out.get(1, 0), img.get(1, 0), 1e-5) && close_px(out.get(2, 0), img.get(2, 0), 1e-5));
|
||||
// Show Mask (White/Black) shows the key; Invert Mask flips it.
|
||||
let mut vals: Vec<(&str, Value)> = key_red.to_vec();
|
||||
vals.extend([("hslSecondary/key/showMask", Value::Bool(true)), ("hslSecondary/key/maskStyle", Value::Enum(2))]);
|
||||
let m = run("ec.color.lumetri", &vals, img.clone());
|
||||
assert_eq!([m.get(0, 0)[0], m.get(1, 0)[0], m.get(2, 0)[0]], [1.0, 0.0, 0.0]);
|
||||
vals.push(("hslSecondary/key/invertMask", Value::Bool(true)));
|
||||
let m = run("ec.color.lumetri", &vals, img.clone());
|
||||
assert_eq!([m.get(0, 0)[0], m.get(1, 0)[0], m.get(2, 0)[0]], [0.0, 1.0, 1.0]);
|
||||
// Lightness band keys only the bright grey; Blur softens the matte across pixels.
|
||||
let vals = [
|
||||
("hslSecondary/key/lightnessLow", num(40.0)),
|
||||
("hslSecondary/key/lightnessHigh", num(60.0)),
|
||||
("hslSecondary/key/showMask", Value::Bool(true)),
|
||||
("hslSecondary/key/maskStyle", Value::Enum(2)),
|
||||
];
|
||||
let m = run("ec.color.lumetri", &vals, img.clone());
|
||||
assert_eq!(m.get(2, 0)[0], 1.0);
|
||||
let mut wide = Image::new(32, 1);
|
||||
for x in 0..32 {
|
||||
wide.set(x, 0, if x < 16 { [0.8, 0.1, 0.1, 1.0] } else { [0.1, 0.8, 0.1, 1.0] });
|
||||
}
|
||||
let mut vals: Vec<(&str, Value)> = key_red.to_vec();
|
||||
vals.extend([
|
||||
("hslSecondary/key/showMask", Value::Bool(true)),
|
||||
("hslSecondary/key/maskStyle", Value::Enum(2)),
|
||||
("hslSecondary/refine/blur", num(50.0)),
|
||||
]);
|
||||
let m = run("ec.color.lumetri", &vals, wide);
|
||||
let v = m.get(16, 0)[0];
|
||||
assert!(v > 0.05 && v < 0.95, "blurred matte edge {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_hdr_range_and_looks() {
|
||||
// HDR White 200 nits: the tone controls see 1.6 as 0.8, so Highlights act on it.
|
||||
let bright = Image::filled(2, 2, [1.6, 1.6, 1.6, 1.0]);
|
||||
let sdr = run("ec.color.lumetri", &[("basicCorrection/tone/highlights", num(-100.0))], bright.clone()).get(0, 0)[0];
|
||||
let hdr = run(
|
||||
"ec.color.lumetri",
|
||||
&[("highDynamicRange", Value::Bool(true)), ("basicCorrection/tone/hdrWhite", num(200.0)), ("basicCorrection/tone/highlights", num(-100.0))],
|
||||
bright.clone(),
|
||||
)
|
||||
.get(0, 0)[0];
|
||||
assert!(hdr < 1.6 - 0.05 && sdr < 1.6, "{sdr} {hdr}");
|
||||
let spec = run("ec.color.lumetri", &[("highDynamicRange", Value::Bool(true)), ("basicCorrection/tone/hdrSpecular", num(100.0))], bright).get(0, 0)[0];
|
||||
assert!((spec - 2.2).abs() < 1e-4, "{spec}");
|
||||
// Built-in looks change the image at Intensity; 0 % leaves it.
|
||||
let img = ramp();
|
||||
let mono = run("ec.color.lumetri", &[("creative/look", Value::Enum(7))], img.clone());
|
||||
let p = mono.get(9, 3);
|
||||
assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5);
|
||||
let none = run("ec.color.lumetri", &[("creative/look", Value::Enum(7)), ("creative/lookIntensity", num(0.0))], img.clone());
|
||||
assert!(close(&none, &img, 1e-5));
|
||||
// A Custom look from inline .cube text (swap red and blue).
|
||||
let mut cube = String::from("LUT_3D_SIZE 2\n");
|
||||
for bb in 0..2 {
|
||||
for g in 0..2 {
|
||||
for r in 0..2 {
|
||||
cube += &format!("{bb} {g} {r}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
let g = Image::filled(1, 1, [0.9, 0.2, 0.1, 1.0]);
|
||||
let o = run("ec.color.lumetri", &[("creative/look", Value::Enum(1)), ("creative/lookFile", Value::Str(cube.clone()))], g.clone()).get(0, 0);
|
||||
assert!((o[0] - 0.1).abs() < 1e-3 && (o[2] - 0.9).abs() < 1e-3, "{o:?}");
|
||||
let o = run("ec.color.lumetri", &[("basicCorrection/inputLut", Value::Enum(1)), ("basicCorrection/inputLutFile", Value::Str(cube))], g).get(0, 0);
|
||||
assert!((o[0] - 0.1).abs() < 1e-3, "{o:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,85 @@ fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
b
|
||||
}
|
||||
|
||||
/// Hue/Saturation's colour ranges (Channel Control entries after Master): id prefix, display
|
||||
/// name and centre hue in degrees.
|
||||
pub const HUESAT_RANGES: [(&str, &str, f64); 6] = [
|
||||
("reds", "Reds", 0.0),
|
||||
("yellows", "Yellows", 60.0),
|
||||
("greens", "Greens", 120.0),
|
||||
("cyans", "Cyans", 180.0),
|
||||
("blues", "Blues", 240.0),
|
||||
("magentas", "Magentas", 300.0),
|
||||
];
|
||||
|
||||
/// Hue/Saturation's Channel Control popup.
|
||||
pub const HUESAT_CHANNELS: [&str; 7] = ["Master", "Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas"];
|
||||
|
||||
/// One colour range of Hue/Saturation: the hues it covers fully (`start..end`, degrees, may
|
||||
/// wrap), the fall-off widths either side, and its hue / saturation / lightness adjustments.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct HueRange {
|
||||
start: f32,
|
||||
end: f32,
|
||||
fall_start: f32,
|
||||
fall_end: f32,
|
||||
hue: f32,
|
||||
sat: f32,
|
||||
light: f32,
|
||||
}
|
||||
|
||||
impl HueRange {
|
||||
/// Membership of hue `h` (degrees): 1 inside the range, ramping to 0 across the fall-offs.
|
||||
fn weight(&self, h: f32) -> f32 {
|
||||
let width = (self.end - self.start).rem_euclid(360.0);
|
||||
let d = (h - self.start).rem_euclid(360.0);
|
||||
if d <= width {
|
||||
return 1.0;
|
||||
}
|
||||
let after = d - width;
|
||||
let before = 360.0 - d;
|
||||
let wa = if self.fall_end > 0.0 { 1.0 - after / self.fall_end } else { 0.0 };
|
||||
let wb = if self.fall_start > 0.0 { 1.0 - before / self.fall_start } else { 0.0 };
|
||||
wa.max(wb).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour ranges with non-zero adjustments.
|
||||
fn huesat_ranges(ctx: &EffectCtx) -> Vec<HueRange> {
|
||||
let f = |id: String, d: f64| ctx.params.get(&id).map(Value::as_f64).unwrap_or(d) as f32;
|
||||
HUESAT_RANGES
|
||||
.iter()
|
||||
.map(|(r, _, c)| HueRange {
|
||||
start: f(format!("{r}RangeStart"), c - 15.0),
|
||||
end: f(format!("{r}RangeEnd"), c + 15.0),
|
||||
fall_start: f(format!("{r}StartFalloff"), 30.0).max(0.0),
|
||||
fall_end: f(format!("{r}EndFalloff"), 30.0).max(0.0),
|
||||
hue: f(format!("{r}Hue"), 0.0) / 360.0,
|
||||
sat: f(format!("{r}Saturation"), 0.0) / 100.0,
|
||||
light: f(format!("{r}Lightness"), 0.0) / 100.0,
|
||||
})
|
||||
.filter(|r| r.hue != 0.0 || r.sat != 0.0 || r.light != 0.0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether no colour range of Hue/Saturation adjusts anything (the GPU kernel does Master and
|
||||
/// Colorize only).
|
||||
pub fn huesat_ranges_identity(ctx: &EffectCtx) -> bool {
|
||||
huesat_ranges(ctx).is_empty()
|
||||
}
|
||||
|
||||
fn adjust_sat(s: f32, sat: f32) -> f32 {
|
||||
if sat >= 0.0 { s + (1.0 - s) * sat * s.min(1.0) } else { s * (1.0 + sat) }
|
||||
}
|
||||
|
||||
fn adjust_light(l: f32, light: f32) -> f32 {
|
||||
if light >= 0.0 { l + (1.0 - l) * light } else { l * (1.0 + light) }
|
||||
}
|
||||
|
||||
/// Hue/Saturation. Master adjusts every pixel; each colour range (Reds … Magentas, chosen in
|
||||
/// Channel Control) adjusts pixels whose original hue lies in its range, fading across the
|
||||
/// fall-offs (greys, having no hue, belong to no range). Colorize replaces hue and
|
||||
/// saturation.
|
||||
fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let hue = ctx.params.f("hue") as f32 / 360.0;
|
||||
let sat = ctx.params.f("saturation") as f32 / 100.0;
|
||||
|
|
@ -78,14 +157,24 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let ch = ctx.params.f("colorizeHue") as f32 / 360.0;
|
||||
let cs = ctx.params.f("colorizeSaturation") as f32 / 100.0;
|
||||
let cl = ctx.params.f("colorizeLightness") as f32 / 100.0;
|
||||
let ranges = huesat_ranges(ctx);
|
||||
b.img.map_straight(|c| {
|
||||
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
|
||||
let (h, s, l) = if colorize {
|
||||
(ch, cs, (l + cl * if cl > 0.0 { 1.0 - l } else { l }).clamp(0.0, 1.0))
|
||||
} else {
|
||||
let s2 = if sat >= 0.0 { s + (1.0 - s) * sat * s.min(1.0) } else { s * (1.0 + sat) };
|
||||
let l2 = if light >= 0.0 { l + (1.0 - l) * light } else { l * (1.0 + light) };
|
||||
((h + hue).rem_euclid(1.0), s2.clamp(0.0, 1.0), l2)
|
||||
let mut s2 = adjust_sat(s, sat);
|
||||
let mut l2 = adjust_light(l, light);
|
||||
let mut h2 = h + hue;
|
||||
for r in &ranges {
|
||||
let w = r.weight(h * 360.0) * (s * 20.0).min(1.0);
|
||||
if w > 0.0 {
|
||||
h2 += r.hue * w;
|
||||
s2 = adjust_sat(s2.clamp(0.0, 1.0), r.sat * w);
|
||||
l2 = adjust_light(l2, r.light * w);
|
||||
}
|
||||
}
|
||||
(h2.rem_euclid(1.0), s2.clamp(0.0, 1.0), l2)
|
||||
};
|
||||
let (r, g, bl) = hsl_to_rgb(h, s, l);
|
||||
[r, g, bl]
|
||||
|
|
@ -107,29 +196,87 @@ pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) {
|
|||
(on("clipToOutputBlack"), on("clipToOutputWhite"))
|
||||
}
|
||||
|
||||
/// Levels' Channel popup.
|
||||
pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"];
|
||||
|
||||
/// Spec-id prefixes of Levels' per-channel controls (`redInBlack`, …; the RGB controls have
|
||||
/// plain ids).
|
||||
pub const LEVELS_CHANNEL_PREFIX: [&str; 4] = ["red", "green", "blue", "alpha"];
|
||||
|
||||
/// Levels' five controls (input black, input white, gamma, output black, output white) for
|
||||
/// Channel popup index `ch` (0 = RGB).
|
||||
pub fn levels_channel_ids(ch: usize) -> [String; 5] {
|
||||
if ch == 0 || ch > 4 {
|
||||
return ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].map(String::from);
|
||||
}
|
||||
let pre = LEVELS_CHANNEL_PREFIX[ch - 1];
|
||||
["InBlack", "InWhite", "Gamma", "OutBlack", "OutWhite"].map(|s| format!("{pre}{s}"))
|
||||
}
|
||||
|
||||
fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool)) -> f32 {
|
||||
let mut t = (v - ib) / (iw - ib).max(1e-6);
|
||||
if clip_b {
|
||||
t = t.max(0.0);
|
||||
}
|
||||
if clip_w {
|
||||
t = t.min(1.0);
|
||||
}
|
||||
let t = t.max(0.0).powf(1.0 / g);
|
||||
ob + (ow - ob) * t
|
||||
}
|
||||
|
||||
const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0];
|
||||
|
||||
/// Levels: the RGB controls map every colour channel, then the Red / Green / Blue controls
|
||||
/// their own channel and the Alpha controls alpha. All five sets are kept; the Channel popup
|
||||
/// only picks the set Effect Controls shows.
|
||||
fn levels(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let ib = ctx.params.f("inBlack") as f32;
|
||||
let iw = ctx.params.f("inWhite") as f32;
|
||||
let g = ctx.params.f("gamma").max(0.01) as f32;
|
||||
let ob = ctx.params.f("outBlack") as f32;
|
||||
let ow = ctx.params.f("outWhite") as f32;
|
||||
let (clip_b, clip_w) = levels_clip(ctx);
|
||||
b.img.map_straight(|c| {
|
||||
c.map(|v| {
|
||||
let mut t = (v - ib) / (iw - ib).max(1e-6);
|
||||
if clip_b {
|
||||
t = t.max(0.0);
|
||||
let clip = levels_clip(ctx);
|
||||
b.img.map_straight(|c| c.map(|v| level(v, [ib, iw, g, ob, ow], clip)));
|
||||
let chans = levels_channel_settings(ctx);
|
||||
let ident = |l: &([f32; 5], (bool, bool))| l.0 == LEVELS_IDENTITY;
|
||||
if chans.iter().all(ident) {
|
||||
return b;
|
||||
}
|
||||
use rayon::prelude::*;
|
||||
b.img.data.par_iter_mut().for_each(|px| {
|
||||
let (c, a) = crate::util::unpremul(*px);
|
||||
let mut o = c;
|
||||
for i in 0..3 {
|
||||
if !ident(&chans[i]) {
|
||||
o[i] = level(c[i], chans[i].0, chans[i].1);
|
||||
}
|
||||
if clip_w {
|
||||
t = t.min(1.0);
|
||||
}
|
||||
let t = t.max(0.0).powf(1.0 / g);
|
||||
ob + (ow - ob) * t
|
||||
})
|
||||
}
|
||||
let na = if ident(&chans[3]) { a } else { level(a, chans[3].0, chans[3].1).clamp(0.0, 1.0) };
|
||||
*px = crate::util::premul(o, na);
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
/// Levels' Red, Green, Blue and Alpha settings and clip switches (identity for projects saved
|
||||
/// before the Channel popup).
|
||||
pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4] {
|
||||
std::array::from_fn(|i| {
|
||||
let ids = levels_channel_ids(i + 1);
|
||||
let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k]));
|
||||
let pre = LEVELS_CHANNEL_PREFIX[i];
|
||||
let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0);
|
||||
let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) };
|
||||
([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether Levels' per-channel controls are all at their defaults (the GPU kernel handles
|
||||
/// only the RGB controls).
|
||||
pub fn levels_channels_identity(ctx: &EffectCtx) -> bool {
|
||||
levels_channel_settings(ctx).iter().all(|l| l.0 == LEVELS_IDENTITY)
|
||||
}
|
||||
|
||||
/// Exposure's per-channel (gain, offset, gamma) for R, G, B: the Master group's values, or the
|
||||
/// Red / Green / Blue groups' with Channels = Individual Channels.
|
||||
pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] {
|
||||
|
|
@ -193,10 +340,66 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
b
|
||||
}
|
||||
|
||||
/// Whether Fill uses masks (Fill Mask or All Masks): the GPU kernel fills alpha only.
|
||||
pub fn fill_uses_masks(ctx: &EffectCtx) -> bool {
|
||||
!ctx.env.masks.is_empty() && (ctx.params.b("allMasks") || ctx.params.f("fillMask") >= 1.0)
|
||||
}
|
||||
|
||||
/// Coverage (0..1, 2×2 supersampled) of the closed polygons `polys` (buffer px) on a `w`×`h`
|
||||
/// grid: the union of the shapes, each inverted when its mask is.
|
||||
pub(crate) fn polys_coverage(w: usize, h: usize, polys: &[(Vec<[f64; 2]>, bool)]) -> crate::util::Plane {
|
||||
use rayon::prelude::*;
|
||||
let mut out = crate::util::Plane::new(w, h);
|
||||
out.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let mut hit = 0.0;
|
||||
for (sx, sy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
|
||||
let (px, py) = (x as f64 + sx, y as f64 + sy);
|
||||
if polys.iter().any(|(p, inv)| crate::util::point_in_poly(p, px, py) != *inv) {
|
||||
hit += 0.25;
|
||||
}
|
||||
}
|
||||
*v = hit;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Fill: paints the layer's pixels with Color. With Fill Mask (or All Masks) only the pixels
|
||||
/// inside that mask (or any mask) are painted, with the mask edge feathered horizontally /
|
||||
/// vertically; Invert paints outside instead. Without a mask, Invert fills the transparent
|
||||
/// areas.
|
||||
fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = ctx.params.color("color");
|
||||
let invert = ctx.params.b("invert");
|
||||
let opacity = ctx.params.f("opacity") as f32 / 100.0;
|
||||
if fill_uses_masks(ctx) {
|
||||
let all = ctx.params.b("allMasks");
|
||||
let idx = ctx.params.f("fillMask").round() as usize;
|
||||
let polys: Vec<(Vec<[f64; 2]>, bool)> = ctx
|
||||
.env
|
||||
.masks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, m)| (all || i + 1 == idx) && m.points.len() >= 3)
|
||||
.map(|(_, m)| (m.points.iter().map(|q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.inverted))
|
||||
.collect();
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let mut cov = polys_coverage(w, h, &polys);
|
||||
let (fh, fv) = (ctx.params.f("horizontalFeather").max(0.0) * b.scale, ctx.params.f("verticalFeather").max(0.0) * b.scale);
|
||||
if fh > 0.0 || fv > 0.0 {
|
||||
cov = crate::util::gauss_plane(&cov, fh * 0.5, fv * 0.5);
|
||||
}
|
||||
use rayon::prelude::*;
|
||||
b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &m)| {
|
||||
let m = if invert { 1.0 - m } else { m } * opacity;
|
||||
let a = px[3];
|
||||
for i in 0..3 {
|
||||
px[i] += (c[i] * a - px[i]) * m;
|
||||
}
|
||||
});
|
||||
return b;
|
||||
}
|
||||
b.img.data.iter_mut().for_each(|px| {
|
||||
let a = if invert { 1.0 - px[3] } else { px[3] };
|
||||
let f = [c[0] * a, c[1] * a, c[2] * a];
|
||||
|
|
@ -431,32 +634,226 @@ fn colorama_phase(c: [f32; 3], a: f32, mode: u32) -> f32 {
|
|||
v.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Colorama with the built-in hue-cycle output palette: the chosen input attribute, shifted by
|
||||
/// Phase Shift and repeated Cycle Repetitions times, picks a colour on the cycle (Interpolate
|
||||
/// Palette off posterizes to the palette's six colours).
|
||||
/// Colorama's Output Cycle presets (our own palettes; Custom uses the Palette parameter).
|
||||
pub const COLORAMA_PRESETS: [&str; 9] = ["Hue Cycle", "Ramp Grayscale", "Fire", "Ice", "Ramp Red", "Ramp Green", "Ramp Blue", "Pastel Cycle", "Custom"];
|
||||
/// Colorama's Modify options: which channels of the pixel take the palette colour.
|
||||
const COLORAMA_MODIFY: [&str; 12] =
|
||||
["All", "Red", "Green", "Blue", "Hue", "Lightness", "Saturation", "Value", "Red & Green", "Green & Blue", "Blue & Red", "None"];
|
||||
|
||||
/// An output cycle: colour stops around the wheel (position 0..1, straight RGBA), cyclic.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ColoramaPalette {
|
||||
pub stops: Vec<(f32, [f32; 4])>,
|
||||
}
|
||||
|
||||
impl ColoramaPalette {
|
||||
/// Built-in palette `i` of [`COLORAMA_PRESETS`].
|
||||
pub fn preset(i: u32) -> ColoramaPalette {
|
||||
let s = |v: &[(f32, [f32; 3])]| ColoramaPalette { stops: v.iter().map(|(p, c)| (*p, [c[0], c[1], c[2], 1.0])).collect() };
|
||||
match i {
|
||||
1 => s(&[(0.0, [0.0; 3]), (0.999, [1.0; 3])]),
|
||||
2 => s(&[(0.0, [0.0, 0.0, 0.0]), (0.3, [0.7, 0.05, 0.0]), (0.6, [1.0, 0.55, 0.0]), (0.85, [1.0, 0.95, 0.4]), (0.999, [1.0, 1.0, 1.0])]),
|
||||
3 => s(&[(0.0, [0.0, 0.0, 0.1]), (0.4, [0.1, 0.3, 0.8]), (0.75, [0.5, 0.85, 1.0]), (0.999, [1.0, 1.0, 1.0])]),
|
||||
4 => s(&[(0.0, [0.0; 3]), (0.999, [1.0, 0.0, 0.0])]),
|
||||
5 => s(&[(0.0, [0.0; 3]), (0.999, [0.0, 1.0, 0.0])]),
|
||||
6 => s(&[(0.0, [0.0; 3]), (0.999, [0.0, 0.0, 1.0])]),
|
||||
7 => s(&[(0.0, [1.0, 0.7, 0.7]), (1.0 / 3.0, [0.7, 1.0, 0.7]), (2.0 / 3.0, [0.7, 0.7, 1.0])]),
|
||||
_ => s(&[
|
||||
(0.0, [1.0, 0.0, 0.0]),
|
||||
(1.0 / 6.0, [1.0, 1.0, 0.0]),
|
||||
(2.0 / 6.0, [0.0, 1.0, 0.0]),
|
||||
(3.0 / 6.0, [0.0, 1.0, 1.0]),
|
||||
(4.0 / 6.0, [0.0, 0.0, 1.0]),
|
||||
(5.0 / 6.0, [1.0, 0.0, 1.0]),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse "pos:r,g,b[,a] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops.
|
||||
pub fn parse(s: &str) -> Option<ColoramaPalette> {
|
||||
let mut stops: Vec<(f32, [f32; 4])> = s
|
||||
.split_whitespace()
|
||||
.filter_map(|t| {
|
||||
let (pos, c) = t.split_once(':')?;
|
||||
let v: Vec<f32> = c.split(',').filter_map(|x| x.trim().parse().ok()).collect();
|
||||
(v.len() >= 3).then(|| (pos.trim().parse::<f32>().ok().map(|p| p.rem_euclid(1.0)), [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)]))
|
||||
})
|
||||
.filter_map(|(p, c)| p.map(|p| (p, c)))
|
||||
.collect();
|
||||
stops.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
(!stops.is_empty()).then_some(ColoramaPalette { stops })
|
||||
}
|
||||
|
||||
/// The colour at phase `ph` (0..1). Interpolate Palette blends between neighbouring stops
|
||||
/// (around the wheel); off, each phase takes the stop at or before it.
|
||||
pub fn eval(&self, ph: f32, interpolate: bool) -> [f32; 4] {
|
||||
let n = self.stops.len();
|
||||
let ph = ph.rem_euclid(1.0);
|
||||
let k = self.stops.iter().rposition(|s| s.0 <= ph).unwrap_or(n - 1);
|
||||
let (p0, c0) = self.stops[k];
|
||||
if !interpolate || n == 1 {
|
||||
return c0;
|
||||
}
|
||||
let (p1, c1) = self.stops[(k + 1) % n];
|
||||
let span = (p1 - p0).rem_euclid(1.0).max(1e-6);
|
||||
let t = ((ph - p0).rem_euclid(1.0) / span).clamp(0.0, 1.0);
|
||||
std::array::from_fn(|i| c0[i] + (c1[i] - c0[i]) * t)
|
||||
}
|
||||
}
|
||||
|
||||
/// How well colour `c` matches `key` (1 = matches) for Pixel Selection's Matching Mode
|
||||
/// (1 RGB, 2 Hue, 3 Chroma), with Matching Tolerance / Softness (0..1).
|
||||
fn colorama_match(c: [f32; 3], key: [f32; 3], mode: u32, tol: f32, soft: f32) -> f32 {
|
||||
let d = match mode {
|
||||
2 => {
|
||||
let (h1, s1, _) = rgb_to_hsl(c[0], c[1], c[2]);
|
||||
let (h2, _, _) = rgb_to_hsl(key[0], key[1], key[2]);
|
||||
let dh = (h1 - h2).abs();
|
||||
dh.min(1.0 - dh) * 2.0 + (1.0 - s1.min(1.0)) * 0.5
|
||||
}
|
||||
3 => {
|
||||
// Chroma: the colour without its brightness.
|
||||
let n = |c: [f32; 3]| {
|
||||
let s = c[0] + c[1] + c[2];
|
||||
if s > 1e-5 { [c[0] / s, c[1] / s, c[2] / s] } else { [1.0 / 3.0; 3] }
|
||||
};
|
||||
let (a, b) = (n(c), n(key));
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() * 1.5
|
||||
}
|
||||
_ => ((c[0] - key[0]).powi(2) + (c[1] - key[1]).powi(2) + (c[2] - key[2]).powi(2)).sqrt() / 3f32.sqrt(),
|
||||
};
|
||||
if d <= tol {
|
||||
1.0
|
||||
} else if soft > 0.0 {
|
||||
(1.0 - (d - tol) / soft).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Colorama: a phase taken from the pixel (Get Phase From, optionally combined with an Add
|
||||
/// Phase layer), shifted and repeated around the Output Cycle, picks a palette colour, which
|
||||
/// replaces the channels Modify names. Pixel Selection limits it to colours near Matching
|
||||
/// Color; Masking to where a mask layer allows; Blend With Original mixes the result back.
|
||||
fn colorama(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let shift = ctx.params.f("inputPhase/phaseShift") as f32 / 360.0;
|
||||
let mode = ctx.params.e("inputPhase/mode");
|
||||
let cycles = ctx.params.f("outputCycle/cycles").max(0.0) as f32;
|
||||
let smooth = ctx.params.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool());
|
||||
let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0;
|
||||
b.img.data.iter_mut().for_each(|px| {
|
||||
let pr = ctx.params;
|
||||
let shift = pr.f("inputPhase/phaseShift") as f32 / 360.0;
|
||||
let mode = pr.e("inputPhase/mode");
|
||||
let cycles = pr.f("outputCycle/cycles").max(0.0) as f32;
|
||||
let smooth = pr.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool());
|
||||
let preset = pr.e("outputCycle/usePresetPalette");
|
||||
let palette = if preset as usize == COLORAMA_PRESETS.len() - 1 { ColoramaPalette::parse(pr.s("outputCycle/palette")) } else { None }
|
||||
.unwrap_or_else(|| ColoramaPalette::preset(preset));
|
||||
let blend = 1.0 - pr.f("blend") as f32 / 100.0;
|
||||
// Add Phase.
|
||||
let add = ctx.layer_param("inputPhase/addPhase", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true));
|
||||
let add_from = pr.e("inputPhase/addPhaseFrom");
|
||||
let add_mode = pr.e("inputPhase/addMode");
|
||||
// Modify.
|
||||
let modify = pr.e("modify/modify");
|
||||
let modify_alpha = pr.b("modify/modifyAlpha");
|
||||
let change_empty = pr.b("modify/changeEmptyPixels");
|
||||
// Pixel Selection.
|
||||
let match_mode = pr.e("pixelSelection/matchingMode");
|
||||
let key = pr.color("pixelSelection/matchingColor");
|
||||
let tol = (pr.f("pixelSelection/matchingTolerance") / 100.0) as f32;
|
||||
let soft = (pr.f("pixelSelection/matchingSoftness") / 100.0) as f32;
|
||||
// Masking.
|
||||
let mask_mode = pr.e("masking/maskingMode");
|
||||
let mask = (mask_mode != 0).then(|| ctx.layer_param("masking/maskLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true))).flatten();
|
||||
let composite = pr.get("masking/compositeOverLayer").is_none_or(|v| v.as_bool());
|
||||
use rayon::prelude::*;
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let a = px[3];
|
||||
if a <= 0.0 {
|
||||
if a <= 0.0 && !change_empty {
|
||||
return;
|
||||
}
|
||||
let c = [px[0] / a, px[1] / a, px[2] / a];
|
||||
let mut ph = (colorama_phase(c, a, mode) * cycles + shift).rem_euclid(1.0);
|
||||
if !smooth {
|
||||
ph = (ph * 6.0).floor() / 6.0;
|
||||
let c = if a > 0.0 { [px[0] / a, px[1] / a, px[2] / a] } else { [0.0; 3] };
|
||||
let mut p = colorama_phase(c, a, mode);
|
||||
if let Some(img) = &add {
|
||||
let (ac, aa) = crate::util::unpremul(img.data[i]);
|
||||
let q = colorama_phase(ac, aa, add_from);
|
||||
p = match add_mode {
|
||||
1 => (p + q).min(1.0),
|
||||
2 => (p + q) * 0.5,
|
||||
3 => p + q - p * q,
|
||||
_ => (p + q).rem_euclid(1.0),
|
||||
};
|
||||
}
|
||||
let (r, g, bl) = effectcraft_color::hsv_to_rgb(ph, 1.0, 1.0);
|
||||
let o = mix(c, [r, g, bl], blend);
|
||||
*px = [o[0] * a, o[1] * a, o[2] * a, a];
|
||||
let ph = (p * cycles + shift).rem_euclid(1.0);
|
||||
let pc = palette.eval(ph, smooth);
|
||||
let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]);
|
||||
let (ph_, ps, pl) = rgb_to_hsl(pc[0], pc[1], pc[2]);
|
||||
let hl = |h: f32, s: f32, l: f32| {
|
||||
let (r, g, bb) = hsl_to_rgb(h, s, l);
|
||||
[r, g, bb]
|
||||
};
|
||||
let new: [f32; 3] = match modify {
|
||||
1 => [pc[0], c[1], c[2]],
|
||||
2 => [c[0], pc[1], c[2]],
|
||||
3 => [c[0], c[1], pc[2]],
|
||||
4 => hl(ph_, s, l),
|
||||
5 => hl(h, s, pl),
|
||||
6 => hl(h, ps, l),
|
||||
7 => {
|
||||
// Value: scale the colour to the palette colour's maximum.
|
||||
let (m, pm) = (c[0].max(c[1]).max(c[2]), pc[0].max(pc[1]).max(pc[2]));
|
||||
if m > 1e-6 { c.map(|v| v * pm / m) } else { [pm; 3] }
|
||||
}
|
||||
8 => [pc[0], pc[1], c[2]],
|
||||
9 => [c[0], pc[1], pc[2]],
|
||||
10 => [pc[0], c[1], pc[2]],
|
||||
11 => c,
|
||||
_ => [pc[0], pc[1], pc[2]],
|
||||
};
|
||||
let new_a = if modify_alpha {
|
||||
pc[3]
|
||||
} else if a > 0.0 {
|
||||
a
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// Where the effect applies: Pixel Selection × Masking.
|
||||
let mut w = if match_mode == 0 { 1.0 } else { colorama_match(c, [key[0], key[1], key[2]], match_mode, tol, soft) };
|
||||
if let Some(m) = &mask {
|
||||
let (mc, ma) = crate::util::unpremul(m.data[i]);
|
||||
let lum = luminance(mc[0], mc[1], mc[2]) * ma;
|
||||
w *= match mask_mode {
|
||||
1 => lum,
|
||||
2 => 1.0 - lum,
|
||||
3 => ma,
|
||||
_ => 1.0 - ma,
|
||||
}
|
||||
.clamp(0.0, 1.0);
|
||||
}
|
||||
let k = w * blend;
|
||||
let o = mix(c, new, k);
|
||||
let oa = a + (new_a - a) * k;
|
||||
// Composite Over Layer off: only the affected pixels remain.
|
||||
let oa = if composite { oa } else { oa * w };
|
||||
*px = [o[0] * oa, o[1] * oa, o[2] * oa, oa.clamp(0.0, 1.0)];
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
/// Levels' Red / Green / Blue / Alpha controls ("Red Input Black", …), in that order.
|
||||
fn levels_channel_params() -> Vec<crate::ParamSpec> {
|
||||
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
|
||||
let names = ["Input Black", "Input White", "Gamma", "Output Black", "Output White"];
|
||||
let mut v = vec![];
|
||||
for (i, pre) in LEVELS_CHANNEL_PREFIX.iter().enumerate() {
|
||||
let label = LEVELS_CHANNELS[i + 1];
|
||||
let ids = levels_channel_ids(i + 1);
|
||||
for k in 0..5 {
|
||||
let ui = if k == 2 { slider(0.1, 10.0, 0.1, 3.0, 2) } else { slider(-1.0, 2.0, 0.0, 1.0, 3) };
|
||||
v.push(p(leak(ids[k].clone()), leak(format!("{label} {}", names[k])), num(LEVELS_IDENTITY[k] as f64), ui));
|
||||
}
|
||||
v.push(p(leak(format!("{pre}ClipToOutputBlack")), leak(format!("{label} Clip To Output Black")), Value::Enum(2), popup(&LEVELS_CLIP)));
|
||||
v.push(p(leak(format!("{pre}ClipToOutputWhite")), leak(format!("{label} Clip To Output White")), Value::Enum(2), popup(&LEVELS_CLIP)));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
let pct = || slider(-100.0, 100.0, -100.0, 100.0, 1);
|
||||
let mut gpg = vec![p("blackStretch", "Black Stretch", num(0.0), slider(0.0, 4.0, 0.0, 4.0, 2))];
|
||||
|
|
@ -501,21 +898,39 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
spec(
|
||||
"ec.color.huesaturation",
|
||||
"Hue/Saturation",
|
||||
vec![
|
||||
p("hue", "Master Hue", num(0.0), ParamUi::Angle),
|
||||
p("saturation", "Master Saturation", num(0.0), pct()),
|
||||
p("lightness", "Master Lightness", num(0.0), pct()),
|
||||
p("colorize", "Colorize", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("colorizeHue", "Colorize Hue", num(0.0), ParamUi::Angle),
|
||||
p("colorizeSaturation", "Colorize Saturation", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
||||
p("colorizeLightness", "Colorize Lightness", num(0.0), pct()),
|
||||
],
|
||||
{
|
||||
let mut v = vec![
|
||||
p("channelControl", "Channel Control", Value::Enum(0), popup(&HUESAT_CHANNELS)),
|
||||
p("hue", "Master Hue", num(0.0), ParamUi::Angle),
|
||||
p("saturation", "Master Saturation", num(0.0), pct()),
|
||||
p("lightness", "Master Lightness", num(0.0), pct()),
|
||||
];
|
||||
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
|
||||
for (r, name, c) in HUESAT_RANGES {
|
||||
let single = name.trim_end_matches('s');
|
||||
v.push(p(leak(format!("{r}Hue")), leak(format!("{single} Hue")), num(0.0), ParamUi::Angle));
|
||||
v.push(p(leak(format!("{r}Saturation")), leak(format!("{single} Saturation")), num(0.0), pct()));
|
||||
v.push(p(leak(format!("{r}Lightness")), leak(format!("{single} Lightness")), num(0.0), pct()));
|
||||
v.push(p(leak(format!("{r}RangeStart")), leak(format!("{single} Range Start")), num(c - 15.0), slider(-360.0, 720.0, -180.0, 360.0, 0)));
|
||||
v.push(p(leak(format!("{r}RangeEnd")), leak(format!("{single} Range End")), num(c + 15.0), slider(-360.0, 720.0, -180.0, 360.0, 0)));
|
||||
v.push(p(leak(format!("{r}StartFalloff")), leak(format!("{single} Start Falloff")), num(30.0), slider(0.0, 180.0, 0.0, 90.0, 0)));
|
||||
v.push(p(leak(format!("{r}EndFalloff")), leak(format!("{single} End Falloff")), num(30.0), slider(0.0, 180.0, 0.0, 90.0, 0)));
|
||||
}
|
||||
v.extend([
|
||||
p("colorize", "Colorize", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("colorizeHue", "Colorize Hue", num(0.0), ParamUi::Angle),
|
||||
p("colorizeSaturation", "Colorize Saturation", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
||||
p("colorizeLightness", "Colorize Lightness", num(0.0), pct()),
|
||||
]);
|
||||
v
|
||||
},
|
||||
hue_saturation,
|
||||
),
|
||||
spec(
|
||||
"ec.color.levels",
|
||||
"Levels",
|
||||
vec![
|
||||
p("channel", "Channel", Value::Enum(0), popup(&LEVELS_CHANNELS)),
|
||||
p("inBlack", "Input Black", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
|
||||
p("inWhite", "Input White", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
|
||||
p("gamma", "Gamma", num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2)),
|
||||
|
|
@ -525,7 +940,10 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&LEVELS_CLIP)),
|
||||
// Projects saved before the two popups: "Don't Clip".
|
||||
p("noClip", "Don't Clip", Value::Bool(false), ParamUi::Hidden),
|
||||
],
|
||||
]
|
||||
.into_iter()
|
||||
.chain(levels_channel_params())
|
||||
.collect(),
|
||||
levels,
|
||||
),
|
||||
spec(
|
||||
|
|
@ -569,8 +987,12 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
name: "Fill",
|
||||
category: "Generate",
|
||||
params: vec![
|
||||
p("fillMask", "Fill Mask", num(0.0), ParamUi::Mask),
|
||||
p("allMasks", "All Masks", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color),
|
||||
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("horizontalFeather", "Horizontal Feather", num(0.0), slider(0.0, 5000.0, 0.0, 100.0, 1)),
|
||||
p("verticalFeather", "Vertical Feather", num(0.0), slider(0.0, 5000.0, 0.0, 100.0, 1)),
|
||||
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
],
|
||||
render: fill,
|
||||
|
|
@ -673,9 +1095,30 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
"Colorama",
|
||||
vec![
|
||||
p("inputPhase/mode", "Get Phase From", Value::Enum(0), popup(&COLORAMA_PHASE)),
|
||||
p("inputPhase/addPhase", "Add Phase", Value::Layer(None), ParamUi::Layer),
|
||||
p("inputPhase/addPhaseFrom", "Add Phase From", Value::Enum(0), popup(&COLORAMA_PHASE)),
|
||||
p("inputPhase/addMode", "Add Mode", Value::Enum(0), popup(&["Wrap", "Clamp", "Average", "Screen"])),
|
||||
p("inputPhase/phaseShift", "Phase Shift", num(0.0), ParamUi::Angle),
|
||||
p("outputCycle/usePresetPalette", "Use Preset Palette", Value::Enum(0), popup(&COLORAMA_PRESETS)),
|
||||
// Use Preset Palette = Custom: "position:r,g,b[,a] …" around the wheel.
|
||||
p(
|
||||
"outputCycle/palette",
|
||||
"Output Cycle",
|
||||
Value::Str("0:1,0,0 0.1667:1,1,0 0.3333:0,1,0 0.5:0,1,1 0.6667:0,0,1 0.8333:1,0,1".into()),
|
||||
ParamUi::Text,
|
||||
),
|
||||
p("outputCycle/cycles", "Cycle Repetitions", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 1)),
|
||||
p("outputCycle/interpolatePalette", "Interpolate Palette", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("modify/modify", "Modify", Value::Enum(0), popup(&COLORAMA_MODIFY)),
|
||||
p("modify/modifyAlpha", "Modify Alpha", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("modify/changeEmptyPixels", "Change Empty Pixels", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("pixelSelection/matchingColor", "Matching Color", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("pixelSelection/matchingTolerance", "Matching Tolerance", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("pixelSelection/matchingSoftness", "Matching Softness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("pixelSelection/matchingMode", "Matching Mode", Value::Enum(0), popup(&["Off", "RGB", "Hue", "Chroma"])),
|
||||
p("masking/maskLayer", "Mask Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("masking/maskingMode", "Masking Mode", Value::Enum(0), popup(&["Off", "Luminance", "Inverted Luminance", "Alpha", "Inverted Alpha"])),
|
||||
p("masking/compositeOverLayer", "Composite Over Layer", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
],
|
||||
colorama,
|
||||
|
|
@ -837,4 +1280,119 @@ mod tests {
|
|||
assert!(px("ec.color.gammapedestalgain", &[("blackStretch", num(4.0))], dark)[0] > 0.15);
|
||||
assert!(close(px("ec.color.gammapedestalgain", &[], dark), dark, 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_channel_controls_act_on_their_channel() {
|
||||
let g = [0.5, 0.5, 0.5, 1.0];
|
||||
assert!(close(px("ec.color.levels", &[("channel", Value::Enum(1))], g), g, 1e-6), "the popup alone changes nothing");
|
||||
let r = px("ec.color.levels", &[("redOutWhite", num(0.5))], g);
|
||||
assert!(close(r, [0.25, 0.5, 0.5, 1.0], 1e-5), "{r:?}");
|
||||
let b = px("ec.color.levels", &[("blueGamma", num(2.0)), ("inWhite", num(0.5))], [0.25, 0.25, 0.25, 1.0]);
|
||||
assert!(close(b, [0.5, 0.5, 0.5f32.sqrt(), 1.0], 1e-5), "RGB first, then blue: {b:?}");
|
||||
let a = px("ec.color.levels", &[("alphaOutWhite", num(0.5))], [0.5, 0.5, 0.5, 1.0]);
|
||||
assert!(close(a, [0.25, 0.25, 0.25, 0.5], 1e-5), "{a:?}");
|
||||
// Per-channel clipping follows its own popups.
|
||||
let hot = [0.9, 0.9, 0.9, 1.0];
|
||||
let c = px("ec.color.levels", &[("greenInWhite", num(0.5))], hot)[1];
|
||||
let nc = px("ec.color.levels", &[("greenInWhite", num(0.5)), ("greenClipToOutputWhite", Value::Enum(0))], hot)[1];
|
||||
assert!((c - 1.0).abs() < 1e-6 && (nc - 1.8).abs() < 1e-5, "{c} {nc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_saturation_ranges_adjust_their_hues_only() {
|
||||
let red = [0.8, 0.1, 0.1, 1.0];
|
||||
let blue = [0.1, 0.1, 0.8, 1.0];
|
||||
let grey = [0.5, 0.5, 0.5, 1.0];
|
||||
let v = [("redsSaturation", num(-100.0))];
|
||||
let r = px("ec.color.huesaturation", &v, red);
|
||||
assert!((r[0] - r[1]).abs() < 1e-4, "reds desaturated: {r:?}");
|
||||
assert!(close(px("ec.color.huesaturation", &v, blue), blue, 1e-5));
|
||||
assert!(close(px("ec.color.huesaturation", &v, grey), grey, 1e-5));
|
||||
// Blues hue +120°: blue becomes red.
|
||||
let b = px("ec.color.huesaturation", &[("bluesHue", num(120.0))], blue);
|
||||
assert!(b[0] > 0.7 && b[2] < 0.2, "{b:?}");
|
||||
// Fall-off: a hue 15° past the range end (Reds: −15..15, fall-off 30) gets half.
|
||||
let orange_ish = {
|
||||
let (r, g, b) = hsl_to_rgb(30.0 / 360.0, 0.8, 0.5);
|
||||
[r, g, b, 1.0]
|
||||
};
|
||||
let o = px("ec.color.huesaturation", &[("redsLightness", num(100.0))], orange_ish);
|
||||
let (_, _, l) = rgb_to_hsl(o[0], o[1], o[2]);
|
||||
assert!((l - 0.75).abs() < 0.01, "half weight: {l}");
|
||||
// Moving the range changes which hues it takes.
|
||||
let o2 = px("ec.color.huesaturation", &[("redsLightness", num(100.0)), ("redsRangeEnd", num(40.0))], orange_ish);
|
||||
assert!(rgb_to_hsl(o2[0], o2[1], o2[2]).2 > 0.99);
|
||||
let params = crate::Params { values: [("redsHue".to_string(), num(10.0))].into_iter().collect() };
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() };
|
||||
assert!(!huesat_ranges_identity(&ctx));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_mask_all_masks_feather_and_invert() {
|
||||
let sq = |x0: f64, x1: f64| crate::MaskShape {
|
||||
name: String::new(),
|
||||
points: vec![[x0, 0.0], [x1, 0.0], [x1, 10.0], [x0, 10.0]],
|
||||
closed: true,
|
||||
inverted: false,
|
||||
};
|
||||
let masks = [sq(0.0, 5.0), sq(15.0, 20.0)];
|
||||
let env = EffectEnv { masks: &masks, ..Default::default() };
|
||||
let grey = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]);
|
||||
let run = |vals: &[(&str, Value)]| run_fx("ec.generate.fill", vals, grey.clone(), 0.0, env).img;
|
||||
let one = run(&[("fillMask", num(1.0))]);
|
||||
assert_eq!(one.get(2, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(one.get(10, 5), [0.5, 0.5, 0.5, 1.0]);
|
||||
assert_eq!(one.get(17, 5), [0.5, 0.5, 0.5, 1.0]);
|
||||
let all = run(&[("allMasks", Value::Bool(true))]);
|
||||
assert_eq!(all.get(17, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
let inv = run(&[("fillMask", num(1.0)), ("invert", Value::Bool(true))]);
|
||||
assert_eq!(inv.get(2, 5), [0.5, 0.5, 0.5, 1.0]);
|
||||
assert_eq!(inv.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
let soft = run(&[("fillMask", num(1.0)), ("horizontalFeather", num(6.0))]);
|
||||
let v = soft.get(6, 5)[1];
|
||||
assert!(v > 0.05 && v < 0.5, "feathered edge {v}");
|
||||
// Without masks, Fill fills the layer as before.
|
||||
let plain = run_fx("ec.generate.fill", &[("fillMask", num(1.0))], grey.clone(), 0.0, EffectEnv::default()).img;
|
||||
assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorama_palettes_modify_selection_masking_and_add_phase() {
|
||||
let grey = [0.5, 0.5, 0.5, 1.0];
|
||||
let red = [1.0, 0.0, 0.0, 1.0];
|
||||
// Ramp Grayscale: intensity 0.5 maps to mid grey.
|
||||
let g = px("ec.color.colorama", &[("outputCycle/usePresetPalette", Value::Enum(1))], grey);
|
||||
assert!((g[0] - 0.5).abs() < 0.01 && (g[0] - g[2]).abs() < 1e-5, "{g:?}");
|
||||
// Custom palette: two stops, black at 0 and blue at 0.5.
|
||||
let custom = [("outputCycle/usePresetPalette", Value::Enum(8)), ("outputCycle/palette", Value::Str("0:0,0,0 0.5:0,0,1".into()))];
|
||||
let c = px("ec.color.colorama", &custom, grey);
|
||||
assert!(c[2] > 0.99 && c[0] < 0.01, "{c:?}");
|
||||
// Modify Lightness keeps the hue of the pixel (red stays red).
|
||||
let l = px("ec.color.colorama", &[("modify/modify", Value::Enum(5)), ("outputCycle/usePresetPalette", Value::Enum(1))], red);
|
||||
assert!(l[0] > l[1] + 0.2 && l[1] < 0.01 + l[2] + 1e-4, "{l:?}");
|
||||
// Pixel Selection: only colours near Matching Color change.
|
||||
let sel = [
|
||||
("pixelSelection/matchingMode", Value::Enum(1)),
|
||||
("pixelSelection/matchingColor", col(1.0, 0.0, 0.0)),
|
||||
("pixelSelection/matchingTolerance", num(10.0)),
|
||||
];
|
||||
assert!(close(px("ec.color.colorama", &sel, grey), grey, 1e-6));
|
||||
assert!(!close(px("ec.color.colorama", &sel, red), red, 1e-3));
|
||||
// Composite Over Layer off: unselected pixels vanish.
|
||||
let mut off = sel.to_vec();
|
||||
off.push(("masking/compositeOverLayer", Value::Bool(false)));
|
||||
assert_eq!(px("ec.color.colorama", &off, grey)[3], 0.0);
|
||||
// Modify Alpha takes the palette alpha.
|
||||
let alpha = [
|
||||
("outputCycle/usePresetPalette", Value::Enum(8)),
|
||||
("outputCycle/palette", Value::Str("0:1,1,1,0.25".into())),
|
||||
("modify/modifyAlpha", Value::Bool(true)),
|
||||
];
|
||||
assert!((px("ec.color.colorama", &alpha, grey)[3] - 0.25).abs() < 1e-5);
|
||||
// Palette parsing and evaluation.
|
||||
let p = ColoramaPalette::parse("0.5:0,0,1 0:1,0,0").unwrap();
|
||||
assert_eq!(p.eval(0.25, true), [0.5, 0.0, 0.5, 1.0]);
|
||||
assert_eq!(p.eval(0.75, true), [0.5, 0.0, 0.5, 1.0], "wraps around");
|
||||
assert_eq!(p.eval(0.25, false), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -257,6 +257,32 @@ fn polar(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
b
|
||||
}
|
||||
|
||||
/// Transform's shutter: (angle, phase) in degrees and the number of samples, or None when it
|
||||
/// renders without motion blur. Use Composition's Shutter Angle takes the comp's shutter
|
||||
/// (only when the comp and layer motion-blur switches are on); otherwise Shutter Angle, centred
|
||||
/// on the frame.
|
||||
pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> {
|
||||
let (angle, phase, n) = if ctx.params.b("useCompositionShutterAngle") {
|
||||
ctx.env.shutter?
|
||||
} else {
|
||||
let a = ctx.params.f("shutterAngle").clamp(0.0, 720.0);
|
||||
(a, -a * 0.5, 16)
|
||||
};
|
||||
(angle > 0.0).then_some((angle, phase, n.clamp(2, 64)))
|
||||
}
|
||||
|
||||
fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 {
|
||||
let anchor = b.to_px(pr.v2("anchor"));
|
||||
let pos = b.to_px(pr.v2("position"));
|
||||
let sh = pr.f("scaleHeight");
|
||||
let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") };
|
||||
Mat3::translate(vec2(pos.0, pos.1))
|
||||
* Mat3::rotate_deg(pr.f("rotation"))
|
||||
* Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis"))
|
||||
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
|
||||
* Mat3::translate(vec2(-anchor.0, -anchor.1))
|
||||
}
|
||||
|
||||
fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let anchor = b.to_px(ctx.params.v2("anchor"));
|
||||
let pos = b.to_px(ctx.params.v2("position"));
|
||||
|
|
@ -268,6 +294,34 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let rot = ctx.params.f("rotation");
|
||||
let opacity = ctx.params.f("opacity") as f32 / 100.0;
|
||||
let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear };
|
||||
// Motion blur: the transform at Samples instants across the shutter, averaged (the
|
||||
// transform at other times comes from the host; without one the frame renders sharp).
|
||||
if let Some((angle, phase, n)) = transform_shutter(ctx)
|
||||
&& let Some(host) = ctx.env.host
|
||||
{
|
||||
let fd = 1.0 / ctx.fps();
|
||||
let mut acc = Image::new(b.img.width, b.img.height);
|
||||
let mut count = 0.0f32;
|
||||
for k in 0..n {
|
||||
let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd;
|
||||
let Some(pr) = host.params_at(t) else { continue };
|
||||
let mut one = Image::new(b.img.width, b.img.height);
|
||||
let op = pr.f("opacity") as f32 / 100.0;
|
||||
effectcraft_raster::composite_warp(
|
||||
&mut one,
|
||||
&b.img,
|
||||
&transform_matrix(&pr, &b),
|
||||
&effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() },
|
||||
);
|
||||
acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c]));
|
||||
count += 1.0;
|
||||
}
|
||||
if count > 0.0 {
|
||||
acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count));
|
||||
b.img = acc;
|
||||
return b;
|
||||
}
|
||||
}
|
||||
let m = Mat3::translate(vec2(pos.0, pos.1))
|
||||
* Mat3::rotate_deg(rot)
|
||||
* Mat3::skew_deg(skew, skew_axis)
|
||||
|
|
@ -397,6 +451,8 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("skewAxis", "Skew Axis", num(0.0), ParamUi::Angle),
|
||||
p("rotation", "Rotation", num(0.0), ParamUi::Angle),
|
||||
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("useCompositionShutterAngle", "Use Composition's Shutter Angle", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("shutterAngle", "Shutter Angle", num(0.0), slider(0.0, 720.0, 0.0, 360.0, 1)),
|
||||
p("sampling", "Sampling", Value::Enum(0), popup(&["Bilinear", "Bicubic"])),
|
||||
],
|
||||
transform,
|
||||
|
|
@ -510,4 +566,44 @@ mod tests {
|
|||
let b = run("ec.distort.transform", &[("rotation", num(17.0)), ("sampling", Value::Enum(1))], im);
|
||||
assert!(a.img.data.iter().zip(&b.img.data).any(|(p, q)| differs(*p, *q)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_shutter_angle_motion_blur() {
|
||||
/// Position moves right 200 px per second.
|
||||
struct Moving;
|
||||
impl crate::EffectHost for Moving {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::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.distort.transform").unwrap();
|
||||
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [40.0, 20.0]))).collect() };
|
||||
p.values.insert("position".into(), Value::Vec2([20.0 + 200.0 * t, 10.0]));
|
||||
Some(p)
|
||||
}
|
||||
}
|
||||
let mut sq = Image::new(40, 20);
|
||||
for y in 5..15 {
|
||||
for x in 15..25 {
|
||||
sq.set(x, y, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
let render = |vals: &[(&str, Value)], shutter: Option<(f64, f64, u32)>| {
|
||||
let env = crate::EffectEnv { host: Some(&Moving), frame_rate: 10.0, shutter, ..Default::default() };
|
||||
crate::run_fx("ec.distort.transform", vals, sq.clone(), 0.0, env).img
|
||||
};
|
||||
let partial = |i: &Image| i.data.iter().filter(|p| p[3] > 0.02 && p[3] < 0.98).count();
|
||||
// Use Composition Shutter Angle with the comp / layer motion blur off: sharp.
|
||||
assert_eq!(partial(&render(&[], None)), 0);
|
||||
// With the comp shutter (180°): smeared along x over 10 px.
|
||||
let comp = render(&[], Some((180.0, -90.0, 16)));
|
||||
assert!(partial(&comp) > 50, "{}", partial(&comp));
|
||||
// Own Shutter Angle.
|
||||
let own = render(&[("useCompositionShutterAngle", Value::Bool(false)), ("shutterAngle", num(360.0))], None);
|
||||
assert!(partial(&own) > partial(&comp));
|
||||
assert_eq!(partial(&render(&[("useCompositionShutterAngle", Value::Bool(false))], None)), 0, "0 deg: sharp");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,26 @@ const TURBULENT_KINDS: [&str; 9] = [
|
|||
"Horizontal Displacement",
|
||||
"Cross Displacement",
|
||||
];
|
||||
const TURBULENT_PINNING: [&str; 8] = ["None", "Pin All", "Pin Horizontal", "Pin Vertical", "Pin Left", "Pin Top", "Pin Right", "Pin Bottom"];
|
||||
/// Turbulent Displace's Pinning options. The Locked variants also keep every displaced
|
||||
/// sample inside the layer (no edge pixels pulled in from outside) and ease the pinning over a
|
||||
/// wider band, so the pinned edges and their neighbourhood stay put.
|
||||
const TURBULENT_PINNING: [&str; 15] = [
|
||||
"None",
|
||||
"Pin All",
|
||||
"Pin Horizontal",
|
||||
"Pin Vertical",
|
||||
"Pin Left",
|
||||
"Pin Top",
|
||||
"Pin Right",
|
||||
"Pin Bottom",
|
||||
"Pin All Locked",
|
||||
"Pin Horizontal Locked",
|
||||
"Pin Vertical Locked",
|
||||
"Pin Left Locked",
|
||||
"Pin Top Locked",
|
||||
"Pin Right Locked",
|
||||
"Pin Bottom Locked",
|
||||
];
|
||||
|
||||
fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let amount = ctx.params.f("amount") * b.scale;
|
||||
|
|
@ -80,10 +99,12 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let evo = ctx.params.f("evolution") / 360.0;
|
||||
// Evolution Options: Cycle Evolution loops the noise every `cycle` revolutions.
|
||||
let cycle = (ctx.params.b("evolutionOptions/cycleEvolution")).then(|| ctx.params.f("evolutionOptions/cycle").round().max(1.0));
|
||||
let pin = ctx.params.e("pinning");
|
||||
let pin_raw = ctx.params.e("pinning");
|
||||
let locked = pin_raw >= 8;
|
||||
let pin = if locked { pin_raw - 7 } else { pin_raw };
|
||||
let falloff = if (3..=5).contains(&kind) { 0.3 } else { 0.5 };
|
||||
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
|
||||
let edge = (lw.min(lh) * 0.1).max(1.0);
|
||||
let edge = (lw.min(lh) * if locked { 0.25 } else { 0.1 }).max(1.0);
|
||||
let seed = (ctx.seed ^ 0x7d15).wrapping_add((ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(0x9e37_79b9));
|
||||
let n = |u: f64, v: f64, s: u32| {
|
||||
let s = seed.wrapping_add(s);
|
||||
|
|
@ -128,7 +149,11 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
7 => bt,
|
||||
_ => 1.0,
|
||||
};
|
||||
Some((x + dx * k, y + dy * k))
|
||||
let (sx, sy) = (x + dx * k, y + dy * k);
|
||||
if locked {
|
||||
return Some((sx.clamp(lx + 0.5, lx + lw - 0.5), sy.clamp(ly + 0.5, ly + lh - 0.5)));
|
||||
}
|
||||
Some((sx, sy))
|
||||
});
|
||||
b
|
||||
}
|
||||
|
|
@ -223,7 +248,10 @@ fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let f = r_ref / half.tan();
|
||||
// Resize (with Reverse Lens Distortion): grow the layer so the stretched corners stay
|
||||
// visible, up to 2× / 4× the layer size (Unlimited is capped at 8×).
|
||||
let resize = ctx.params.e("resize");
|
||||
// Optimal Pixels (invalidates Resize): scale the result so the whole corrected picture
|
||||
// fits the layer (Reverse) or fills it with no empty corners (forward).
|
||||
let optimal = ctx.params.b("optimalPixels");
|
||||
let resize = if optimal { 0 } else { ctx.params.e("resize") };
|
||||
if reverse && resize > 0 && !ctx.adjustment {
|
||||
let c = b.to_px(ctx.params.v2("viewCenter"));
|
||||
let rc =
|
||||
|
|
@ -234,9 +262,21 @@ fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
b.pad(grow.min(cap).min(4096.0).ceil() as u32);
|
||||
}
|
||||
let c = b.to_px(ctx.params.v2("viewCenter"));
|
||||
let fit = if optimal {
|
||||
let rc =
|
||||
[(lx, ly), (lx + lw, ly), (lx, ly + lh), (lx + lw, ly + lh)].iter().map(|p| ((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt()).fold(1.0, f64::max);
|
||||
if reverse {
|
||||
let th = (rc / r_ref * half).min(FRAC_PI_2 - 1e-3);
|
||||
(f * th.tan() / rc).max(1.0)
|
||||
} else {
|
||||
((rc / f).atan() / half * r_ref / rc).min(1.0)
|
||||
}
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
b.img = remap(&b.img, false, |x, y| {
|
||||
let (dx, dy) = (x - c.0, y - c.1);
|
||||
let r = (dx * dx + dy * dy).sqrt();
|
||||
let r = (dx * dx + dy * dy).sqrt() * fit;
|
||||
if r < 1e-9 {
|
||||
return Some((x, y));
|
||||
}
|
||||
|
|
@ -249,7 +289,7 @@ fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
}
|
||||
f * th.tan()
|
||||
};
|
||||
let k = rs / r;
|
||||
let k = rs / r * fit;
|
||||
Some((c.0 + dx * k, c.1 + dy * k))
|
||||
});
|
||||
b
|
||||
|
|
@ -718,6 +758,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("reverseLensDistortion", "Reverse Lens Distortion", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("fovOrientation", "FOV Orientation", Value::Enum(0), popup(&["Horizontal", "Vertical", "Diagonal"])),
|
||||
p("viewCenter", "View Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("optimalPixels", "Optimal Pixels (Invalidates Resize)", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("resize", "Resize", Value::Enum(0), popup(&["Off", "Max 2X", "Max 4X", "Unlimited"])),
|
||||
],
|
||||
optics_compensation,
|
||||
|
|
@ -1052,6 +1093,15 @@ mod tests {
|
|||
// Random Seed changes the pattern.
|
||||
let seeded = run("ec.distort.turbulentdisplace", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], img);
|
||||
assert!(max_diff(&seeded.img, &pinned.img) > 1e-3);
|
||||
// Locked pinning: no sample comes from outside the layer (no transparent pixels pulled
|
||||
// in) and the border still holds.
|
||||
let opaque = Image::filled(40, 40, [0.5, 0.5, 0.5, 1.0]);
|
||||
let strong = [("size", num(10.0)), ("amount", num(80.0))];
|
||||
let left = run("ec.distort.turbulentdisplace", &[strong.as_slice(), &[("pinning", Value::Enum(4))]].concat(), opaque.clone());
|
||||
let locked = run("ec.distort.turbulentdisplace", &[strong.as_slice(), &[("pinning", Value::Enum(11))]].concat(), opaque.clone());
|
||||
assert!(left.img.data.iter().any(|p| p[3] < 0.99), "plain pin lets edges pull in");
|
||||
assert!(locked.img.data.iter().all(|p| p[3] > 0.999), "locked keeps everything inside");
|
||||
assert_eq!(TURBULENT_PINNING[11], "Pin Left Locked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1078,4 +1128,20 @@ mod tests {
|
|||
let max4 = run("ec.distort.opticscompensation", &set(2), img);
|
||||
assert!(max4.img.width >= max2.img.width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optics_optimal_pixels_fills_or_fits_the_layer() {
|
||||
let img = Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]);
|
||||
// Forward: without Optimal Pixels the corners sample outside the layer.
|
||||
let plain = run("ec.distort.opticscompensation", &[("fieldOfView", num(120.0))], img.clone());
|
||||
assert!(plain.img.get(0, 0)[3] < 0.5);
|
||||
let opt = run("ec.distort.opticscompensation", &[("fieldOfView", num(120.0)), ("optimalPixels", Value::Bool(true))], img.clone());
|
||||
assert!(opt.img.get(0, 0)[3] > 0.9 && opt.img.get(31, 23)[3] > 0.9);
|
||||
// Reverse: everything fits; Resize is ignored.
|
||||
let rev = [("fieldOfView", num(120.0)), ("reverseLensDistortion", Value::Bool(true)), ("optimalPixels", Value::Bool(true)), ("resize", Value::Enum(2))];
|
||||
let r = run("ec.distort.opticscompensation", &rev, img.clone());
|
||||
assert_eq!(r.img.width, 32);
|
||||
// The edges of the source now sit inside the layer: empty space around them.
|
||||
assert!(r.img.get(0, 0)[3] < 0.5 && r.img.get(16, 12)[3] > 0.9);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -428,6 +428,42 @@ fn smear(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
|
||||
// ---------------------------------------------------------------- Reshape
|
||||
|
||||
/// Reshape's correspondence points: "s,d s,d …" (outline fractions, sorted by destination).
|
||||
fn parse_correspondence(s: &str) -> Vec<(f64, f64)> {
|
||||
let mut v: Vec<(f64, f64)> = s
|
||||
.split_whitespace()
|
||||
.filter_map(|t| {
|
||||
let (a, b) = t.split_once(',')?;
|
||||
Some((a.trim().parse::<f64>().ok()?.rem_euclid(1.0), b.trim().parse::<f64>().ok()?.rem_euclid(1.0)))
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
v
|
||||
}
|
||||
|
||||
/// The source outline fraction matching destination fraction `u` (piecewise linear between
|
||||
/// correspondence pairs; around the loop for closed shapes).
|
||||
fn map_fraction(pairs: &[(f64, f64)], u: f64, closed: bool) -> f64 {
|
||||
let n = pairs.len();
|
||||
if n == 1 {
|
||||
return (pairs[0].0 + (u - pairs[0].1)).rem_euclid(1.0);
|
||||
}
|
||||
if !closed {
|
||||
// Open paths: clamp beyond the first / last pair (ends map to ends).
|
||||
let ext: Vec<(f64, f64)> = std::iter::once((0.0, 0.0)).chain(pairs.iter().copied()).chain(std::iter::once((1.0, 1.0))).collect();
|
||||
let k = ext.iter().rposition(|p| p.1 <= u).unwrap_or(0).min(ext.len() - 2);
|
||||
let (a, b) = (ext[k], ext[k + 1]);
|
||||
let t = if b.1 > a.1 { (u - a.1) / (b.1 - a.1) } else { 0.0 };
|
||||
return (a.0 + (b.0 - a.0) * t).clamp(0.0, 1.0);
|
||||
}
|
||||
let k = pairs.iter().rposition(|p| p.1 <= u).unwrap_or(n - 1);
|
||||
let (a, b) = (pairs[k], pairs[(k + 1) % n]);
|
||||
let du = (b.1 - a.1).rem_euclid(1.0).max(1e-9);
|
||||
let ds = (b.0 - a.0).rem_euclid(1.0);
|
||||
let t = (u - a.1).rem_euclid(1.0) / du;
|
||||
(a.0 + ds * t).rem_euclid(1.0)
|
||||
}
|
||||
|
||||
fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let (Some((sm, sc)), Some((dm, dc))) = (mask_px(ctx, &b, "sourceMask"), mask_px(ctx, &b, "destinationMask")) else { return b };
|
||||
let pct = ctx.params.f("percent") / 100.0;
|
||||
|
|
@ -450,7 +486,25 @@ fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
} else {
|
||||
0
|
||||
};
|
||||
let disp: Vec<[f64; 2]> = (0..N).map(|i| [s[(i + rot) % N][0] - d[i][0], s[(i + rot) % N][1] - d[i][1]]).collect();
|
||||
// Correspondence points ("source,destination" pairs of outline fractions 0..1) pin which
|
||||
// point of the source outline goes to which point of the destination outline; in between
|
||||
// the outlines are matched proportionally by length. Without them the starting point is
|
||||
// chosen automatically (above).
|
||||
let pairs = parse_correspondence(ctx.params.s("correspondencePoints"));
|
||||
let src_at = |i: usize| -> [f64; 2] {
|
||||
if pairs.is_empty() {
|
||||
return s[(i + rot) % N];
|
||||
}
|
||||
let u = map_fraction(&pairs, i as f64 / N as f64, sc && dc);
|
||||
let len = crate::util::poly_length(&sm, sc);
|
||||
crate::util::poly_point_at(&sm, sc, u * len).0
|
||||
};
|
||||
let disp: Vec<[f64; 2]> = (0..N)
|
||||
.map(|i| {
|
||||
let q = src_at(i);
|
||||
[q[0] - d[i][0], q[1] - d[i][1]]
|
||||
})
|
||||
.collect();
|
||||
let smooth = ctx.params.e("interpolation") == 2;
|
||||
b.img = remap(&b.img, false, |x, y| {
|
||||
if let Some(bd) = &bound
|
||||
|
|
@ -839,6 +893,8 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("percent", "Percent", num(100.0), pct()),
|
||||
p("elasticity", "Elasticity", Value::Enum(0), popup(&ELASTICITY)),
|
||||
p("interpolation", "Interpolation Method", Value::Enum(1), popup(&["Discrete", "Linear", "Smooth"])),
|
||||
// Correspondence points: "source,destination" outline fractions (0..1) per point.
|
||||
p("correspondencePoints", "Correspondence Points", Value::Str(String::new()), ParamUi::Hidden),
|
||||
],
|
||||
reshape,
|
||||
),
|
||||
|
|
@ -1029,6 +1085,22 @@ mod tests {
|
|||
assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_correspondence_points_steer_the_match() {
|
||||
// Matching outlines with a zero pair change nothing; a quarter-turn offset twists.
|
||||
let im = img(40, 40);
|
||||
let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)];
|
||||
let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))];
|
||||
let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks);
|
||||
assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change");
|
||||
let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks);
|
||||
assert!(maxd(&twisted.img, &im) > 0.1);
|
||||
// Fraction mapping.
|
||||
assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9);
|
||||
assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9);
|
||||
assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upscale_grows_about_centre() {
|
||||
let im = img(20, 10);
|
||||
|
|
|
|||
|
|
@ -435,12 +435,15 @@ fn median(v: &mut [f64]) -> f64 {
|
|||
|
||||
/// Measure motion around the current frame (`cur`) from `prev` to `next` (all in the same
|
||||
/// buffer grid). `per_row`: Warp method (one velocity per scanline band).
|
||||
fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool) -> Option<Flow> {
|
||||
/// `detail` (Pixel Motion Detail, 0..1; 0.2 = the standard grid) scales the number of motion
|
||||
/// vectors the Pixel Motion method measures: more vectors follow finer local motion.
|
||||
fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool, detail: f64) -> Option<Flow> {
|
||||
use effectcraft_track::klt::{GrayPyramid, LkOpts, analysis_factor, track};
|
||||
let factor = analysis_factor(cur.width, cur.height, if detailed { 640 } else { 320 });
|
||||
let pc = GrayPyramid::from_image(cur, [0.0; 2], factor, 4);
|
||||
let (w, h) = (pc.width(), pc.height());
|
||||
let n = if detailed { 24 } else { 12 };
|
||||
let base = if detailed { 24.0 } else { 12.0 };
|
||||
let n = if per_row { base as usize } else { (base * (0.5 + detail.clamp(0.0, 1.0) * 2.5)).round().clamp(4.0, 96.0) as usize };
|
||||
let cell_w = w as f64 / n as f64;
|
||||
let rows = ((n as f64) * h as f64 / w.max(1) as f64).round().max(2.0) as usize;
|
||||
let cell_h = h as f64 / rows as f64;
|
||||
|
|
@ -520,7 +523,17 @@ fn rolling_shutter(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
return b;
|
||||
}
|
||||
let per_row = ctx.params.e("advanced/method") == 0;
|
||||
let Some(flow) = measure(&b.img, prev.as_ref(), next.as_ref(), 1.0, ctx.params.b("advanced/detailedAnalysis"), per_row) else { return b };
|
||||
let Some(flow) = measure(
|
||||
&b.img,
|
||||
prev.as_ref(),
|
||||
next.as_ref(),
|
||||
1.0,
|
||||
ctx.params.b("advanced/detailedAnalysis"),
|
||||
per_row,
|
||||
ctx.params.f("advanced/pixelMotionDetail") / 100.0,
|
||||
) else {
|
||||
return b;
|
||||
};
|
||||
let dir = ctx.params.e("scanDirection");
|
||||
let src = b.img.clone();
|
||||
let (w, h) = (b.img.width as f64, b.img.height as f64);
|
||||
|
|
@ -740,6 +753,20 @@ mod tests {
|
|||
);
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
}
|
||||
// Pixel Motion Detail changes how many vectors Pixel Motion measures (and still repairs).
|
||||
let detail = |d: f64| {
|
||||
run_fx(
|
||||
"ec.distort.rollingshutterrepair",
|
||||
&[("rollingShutterRate", num(100.0)), ("advanced/method", Value::Enum(1)), ("advanced/pixelMotionDetail", num(d))],
|
||||
skewed.clone(),
|
||||
t,
|
||||
env,
|
||||
)
|
||||
.img
|
||||
};
|
||||
let (fine, coarse) = (detail(100.0), detail(0.0));
|
||||
assert_ne!(fine.data, coarse.data);
|
||||
assert!(err(&fine) < err(&skewed) * 0.5 && err(&coarse) < err(&skewed) * 0.5);
|
||||
let none = run_fx("ec.distort.rollingshutterrepair", &[], skewed.clone(), t, EffectEnv::default());
|
||||
assert_eq!(none.img.data, skewed.data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,8 +43,9 @@ fn add_light(px: Px, l: [f32; 3], la: f32) -> Px {
|
|||
|
||||
// ---------------------------------------------------------------- Cell Pattern
|
||||
|
||||
/// Cell Pattern options. The HQ variants share the shaping of their standard counterparts
|
||||
/// (we always render at full precision).
|
||||
/// Cell Pattern options. The HQ variants share the shaping of their standard counterparts but
|
||||
/// search a wider neighbourhood of cells (no artefacts at high Disperse) and anti-alias the
|
||||
/// cell edges (4×4 samples per pixel).
|
||||
const CELL_PATTERNS: [&str; 12] = [
|
||||
"Bubbles",
|
||||
"Crystals",
|
||||
|
|
@ -75,13 +76,13 @@ pub fn pattern_kind(i: u32) -> u32 {
|
|||
|
||||
/// Worley distances for point (u, v) in cell units: (F1, F2, hash of the nearest cell).
|
||||
/// `tile` repeats the cell layout every (columns, rows) cells.
|
||||
fn worley(u: f64, v: f64, disperse: f64, evo: f64, seed: u32, tile: Option<(i64, i64)>) -> (f64, f64, f32) {
|
||||
fn worley_r(u: f64, v: f64, disperse: f64, evo: f64, seed: u32, tile: Option<(i64, i64)>, reach: i64) -> (f64, f64, f32) {
|
||||
let (cx, cy) = (u.floor() as i64, v.floor() as i64);
|
||||
let mut f1 = f64::INFINITY;
|
||||
let mut f2 = f64::INFINITY;
|
||||
let mut id = 0.0f32;
|
||||
for j in -1..=1 {
|
||||
for i in -1..=1 {
|
||||
for j in -reach..=reach {
|
||||
for i in -reach..=reach {
|
||||
let (gx, gy) = (cx + i, cy + j);
|
||||
let (tx, ty) = match tile {
|
||||
Some((nx, ny)) => (gx.rem_euclid(nx), gy.rem_euclid(ny)),
|
||||
|
|
@ -135,6 +136,7 @@ fn overflow(v: f32, mode: u32) -> f32 {
|
|||
|
||||
fn cell_pattern(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pattern = pattern_kind(ctx.params.e("cellPattern"));
|
||||
let hq = (6..=10).contains(&ctx.params.e("cellPattern"));
|
||||
let invert = ctx.params.b("invert");
|
||||
let contrast = ctx.params.f("contrast") as f32 / 100.0;
|
||||
let ov = ctx.params.e("overflow");
|
||||
|
|
@ -152,11 +154,19 @@ fn cell_pattern(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
.params
|
||||
.b("tilingOptions/enableTiling")
|
||||
.then(|| (ctx.params.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64, ctx.params.f("tilingOptions/cellsVertical").round().max(1.0) as i64));
|
||||
let subs: Vec<f64> = if hq { (0..4).map(|k| (k as f64 + 0.5) / 4.0).collect() } else { vec![0.5] };
|
||||
let reach = if hq { 2 } else { 1 };
|
||||
map_xy(&mut b.img, |x, y, _| {
|
||||
let u = (x as f64 + 0.5 - off.0) / size;
|
||||
let v = (y as f64 + 0.5 - off.1) / size;
|
||||
let (f1, f2, id) = worley(u, v, disperse, evo, seed, tile);
|
||||
let mut val = cell_value(pattern, f1, f2, id);
|
||||
let mut val = 0.0;
|
||||
for sy in &subs {
|
||||
for sx in &subs {
|
||||
let u = (x as f64 + sx - off.0) / size;
|
||||
let v = (y as f64 + sy - off.1) / size;
|
||||
let (f1, f2, id) = worley_r(u, v, disperse, evo, seed, tile, reach);
|
||||
val += cell_value(pattern, f1, f2, id);
|
||||
}
|
||||
}
|
||||
let mut val = val / (subs.len() * subs.len()) as f32;
|
||||
val = overflow((val - 0.5) * contrast + 0.5, ov);
|
||||
if invert {
|
||||
val = 1.0 - val;
|
||||
|
|
@ -349,60 +359,275 @@ fn outline_dist(pts: &[(f64, f64)], dx: f64, dy: f64) -> f64 {
|
|||
best
|
||||
}
|
||||
|
||||
/// Radio Waves' Wave Type options.
|
||||
const RW_TYPES: [&str; 3] = ["Polygon", "Image Contours", "Mask"];
|
||||
/// Radio Waves' Stroke Profile options.
|
||||
const RW_PROFILES: [&str; 7] = ["Square", "Taper Front", "Taper Back", "Taper Both", "Sine", "Sawtooth Out", "Sawtooth In"];
|
||||
/// Image Contour's Value Channel options.
|
||||
const RW_CHANNELS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Hue", "Lightness", "Saturation"];
|
||||
|
||||
/// Stroke Profile weight across the stroke: `u` from −1 (inner edge) to 1 (outer edge).
|
||||
fn rw_profile(kind: u32, u: f64) -> f64 {
|
||||
let u = u.clamp(-1.0, 1.0);
|
||||
match kind {
|
||||
1 => (1.0 - u) * 0.5,
|
||||
2 => (1.0 + u) * 0.5,
|
||||
3 => 1.0 - u.abs(),
|
||||
4 => (u * PI * 0.5).cos(),
|
||||
5 if u < 0.0 => 1.0 + u,
|
||||
6 if u > 0.0 => 1.0 - u,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a coordinate into `[lo, hi]` by bouncing off the ends (Reflection).
|
||||
fn reflect(v: f64, lo: f64, hi: f64) -> f64 {
|
||||
let span = hi - lo;
|
||||
if span <= 0.0 {
|
||||
return lo;
|
||||
}
|
||||
let t = (v - lo).rem_euclid(2.0 * span);
|
||||
lo + if t > span { 2.0 * span - t } else { t }
|
||||
}
|
||||
|
||||
/// The source shape of a contour wave: signed distance (buffer px, negative inside) on the
|
||||
/// buffer grid, and the point of the shape that sits on the Producer Point.
|
||||
struct Contour {
|
||||
sdf: Plane,
|
||||
anchor: (f64, f64),
|
||||
}
|
||||
|
||||
/// Image Contours: the region of the source layer whose Value Channel passes Value Threshold.
|
||||
fn image_contour(ctx: &EffectCtx, b: &Buf) -> Option<Contour> {
|
||||
let pr = ctx.params;
|
||||
let lp = ctx.layer_param("imageContour/sourceLayer", true)?;
|
||||
let img = crate::util::fit_layer(ctx, b, &lp, false);
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let ch = pr.e("imageContour/valueChannel");
|
||||
let mut v = Plane::from_image(&img, |px| {
|
||||
let (c, a) = unpremul(px);
|
||||
let (hh, s, l) = effectcraft_color::rgb_to_hsl(c[0], c[1], c[2]);
|
||||
match ch {
|
||||
0 => c[0] * a,
|
||||
1 => c[1] * a,
|
||||
2 => c[2] * a,
|
||||
3 => a,
|
||||
5 => hh,
|
||||
6 => l * a,
|
||||
7 => s,
|
||||
_ => luminance(c[0], c[1], c[2]) * a,
|
||||
}
|
||||
});
|
||||
let blur = pr.f("imageContour/preBlur").max(0.0) * b.scale;
|
||||
if blur > 0.0 {
|
||||
v = gauss_plane(&v, blur * 0.5, blur * 0.5);
|
||||
}
|
||||
let thr = (pr.f("imageContour/valueThreshold") / 255.0) as f32;
|
||||
let invert = pr.b("imageContour/invertInput");
|
||||
let mut inside: Vec<bool> = v.data.iter().map(|&x| (x >= thr) != invert).collect();
|
||||
// Contour: 0 = every region; n = the n-th largest connected region.
|
||||
let pick = pr.f("imageContour/contour").round() as usize;
|
||||
if pick > 0 {
|
||||
let mut label = vec![0u32; w * h];
|
||||
let mut sizes = vec![0usize];
|
||||
for start in 0..w * h {
|
||||
if !inside[start] || label[start] != 0 {
|
||||
continue;
|
||||
}
|
||||
let id = sizes.len() as u32;
|
||||
let mut stack = vec![start];
|
||||
label[start] = id;
|
||||
let mut n = 0;
|
||||
while let Some(i) = stack.pop() {
|
||||
n += 1;
|
||||
let (x, y) = (i % w, i / w);
|
||||
let mut nb = |j: usize| {
|
||||
if inside[j] && label[j] == 0 {
|
||||
label[j] = id;
|
||||
stack.push(j);
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
nb(i - 1);
|
||||
}
|
||||
if x + 1 < w {
|
||||
nb(i + 1);
|
||||
}
|
||||
if y > 0 {
|
||||
nb(i - w);
|
||||
}
|
||||
if y + 1 < h {
|
||||
nb(i + w);
|
||||
}
|
||||
}
|
||||
sizes.push(n);
|
||||
}
|
||||
let mut order: Vec<usize> = (1..sizes.len()).collect();
|
||||
order.sort_by(|a, c| sizes[*c].cmp(&sizes[*a]).then(a.cmp(c)));
|
||||
let keep = order.get(pick - 1).copied().unwrap_or(usize::MAX) as u32;
|
||||
inside.iter_mut().zip(&label).for_each(|(v, l)| *v = *v && *l == keep);
|
||||
}
|
||||
if !inside.iter().any(|v| *v) {
|
||||
return None;
|
||||
}
|
||||
let mut sdf = crate::util::signed_distance(&inside, w, h);
|
||||
// Tolerance smooths the traced outline.
|
||||
let tol = pr.f("imageContour/tolerance").max(0.0) * b.scale;
|
||||
if tol > 0.0 {
|
||||
sdf = gauss_plane(&sdf, tol * 0.5, tol * 0.5);
|
||||
}
|
||||
Some(Contour { sdf, anchor: b.to_px(pr.v2("imageContour/sourceCenter")) })
|
||||
}
|
||||
|
||||
/// Mask: the chosen mask of this layer.
|
||||
fn mask_contour(ctx: &EffectCtx, b: &Buf) -> Option<Contour> {
|
||||
let idx = ctx.params.f("waveMask/mask").round() as usize;
|
||||
let (pts, _, inverted) = crate::generate3::mask_px(ctx, b, idx)?;
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let inside: Vec<bool> = (0..w * h).map(|i| crate::util::point_in_poly(&pts, (i % w) as f64 + 0.5, (i / w) as f64 + 0.5) != inverted).collect();
|
||||
if !inside.iter().any(|v| *v) {
|
||||
return None;
|
||||
}
|
||||
// Mask waves start where the mask is (anchored at the Producer Point: no shift).
|
||||
let anchor = b.to_px(ctx.params.v2("producerPoint"));
|
||||
Some(Contour { sdf: crate::util::signed_distance(&inside, w, h), anchor })
|
||||
}
|
||||
|
||||
/// One live wave.
|
||||
struct Wave {
|
||||
centre: (f64, f64),
|
||||
radius: f64,
|
||||
half_width: f64,
|
||||
rotation: f64,
|
||||
fade: f32,
|
||||
color: [f32; 4],
|
||||
opacity: f32,
|
||||
/// Polygon outline (unit shape rotated and scaled), or None for a circle / contour.
|
||||
outline: Option<Vec<(f64, f64)>>,
|
||||
}
|
||||
|
||||
/// Radio Waves: waves are born at Frequency per second at the Producer Point and grow at
|
||||
/// Expansion px/s for Lifespan seconds, moving at Velocity towards Direction (bouncing off the
|
||||
/// layer edges with Reflection) and spinning. Polygon waves are regular polygons or stars;
|
||||
/// Image Contours waves ripple outward from the outline of a source layer's thresholded
|
||||
/// channel, Mask waves from a mask. With Parameters Are Set At Birth each wave keeps the values
|
||||
/// it was born with; Each Frame applies the current values to every wave. Stroke draws each wave
|
||||
/// with its Profile across the width.
|
||||
fn radio_waves(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let c = b.to_px(ctx.params.v2("producerPoint"));
|
||||
let sides = ctx.params.f("polygon/sides").round().clamp(3.0, 64.0);
|
||||
let star = ctx.params.b("polygon/star");
|
||||
let depth = ctx.params.f("polygon/starDepth").clamp(-1.0, 1.0);
|
||||
let freq = ctx.params.f("waveMotion/frequency").max(0.01);
|
||||
let expansion = ctx.params.f("waveMotion/expansion") * b.scale;
|
||||
let orient = ctx.params.f("waveMotion/orientation").to_radians();
|
||||
// Direction: 0° = up, clockwise.
|
||||
let dirn = ctx.params.f("waveMotion/direction").to_radians();
|
||||
let vel = ctx.params.f("waveMotion/velocity") * b.scale;
|
||||
let spin = ctx.params.f("waveMotion/spin").to_radians();
|
||||
let life = ctx.params.f("waveMotion/lifespan").max(0.01);
|
||||
let color = ctx.params.color("waveStroke/color");
|
||||
let op = (ctx.params.f("waveStroke/opacity") / 100.0) as f32;
|
||||
let fin = ctx.params.f("waveStroke/fadeInTime").max(0.0);
|
||||
let fout = ctx.params.f("waveStroke/fadeOutTime").max(0.0);
|
||||
let sw = ctx.params.f("waveStroke/startWidth") * b.scale;
|
||||
let ew = ctx.params.f("waveStroke/endWidth") * b.scale;
|
||||
let cur = ctx.params;
|
||||
let t = ctx.time.max(0.0);
|
||||
let freq = cur.f("waveMotion/frequency").max(0.01);
|
||||
let life_now = cur.f("waveMotion/lifespan").max(0.01);
|
||||
let birth = cur.e("parametersAreSetAt") == 0;
|
||||
let wave_type = cur.e("waveType");
|
||||
let quality = cur.get("renderQuality").map(Value::as_f64).unwrap_or(4.0).clamp(1.0, 10.0);
|
||||
let aa = 0.5 + 1.5 / quality;
|
||||
let profile = cur.e("waveStroke/profile");
|
||||
let reflection = cur.b("waveMotion/reflection");
|
||||
let (lx, ly, lw, lh) = layer_rect(ctx, &b);
|
||||
let contour = match wave_type {
|
||||
1 => image_contour(ctx, &b),
|
||||
2 => mask_contour(ctx, &b),
|
||||
_ => None,
|
||||
};
|
||||
if wave_type != 0 && contour.is_none() {
|
||||
return b;
|
||||
}
|
||||
let k_hi = (t * freq).floor() as i64;
|
||||
let k_lo = (((t - life) * freq).floor() as i64).max(0).max(k_hi - 256);
|
||||
// (centre, radius, half width, rotation, fade) per live wave.
|
||||
let waves: Vec<((f64, f64), f64, f64, f64, f32)> = (k_lo..=k_hi)
|
||||
let k_lo = (((t - life_now) * freq).floor() as i64 - 1).max(0).max(k_hi - 256);
|
||||
let mut cache: std::collections::HashMap<i64, crate::Params> = std::collections::HashMap::new();
|
||||
let waves: Vec<Wave> = (k_lo..=k_hi)
|
||||
.filter_map(|k| {
|
||||
let age = t - k as f64 / freq;
|
||||
let born = k as f64 / freq;
|
||||
let age = t - born;
|
||||
// Birth: the values the wave was born with (when the host can tell).
|
||||
let pr: &crate::Params = if birth {
|
||||
if let Some(h) = ctx.env.host
|
||||
&& let std::collections::hash_map::Entry::Vacant(e) = cache.entry(k)
|
||||
&& let Some(p) = h.params_at(born)
|
||||
{
|
||||
e.insert(p);
|
||||
}
|
||||
cache.get(&k).unwrap_or(cur)
|
||||
} else {
|
||||
cur
|
||||
};
|
||||
let life = pr.f("waveMotion/lifespan").max(0.01);
|
||||
if !(0.0..life).contains(&age) {
|
||||
return None;
|
||||
}
|
||||
let c = b.to_px(pr.v2("producerPoint"));
|
||||
let fin = pr.f("waveStroke/fadeInTime").max(0.0);
|
||||
let fout = pr.f("waveStroke/fadeOutTime").max(0.0);
|
||||
let fi = if fin > 0.0 { (age / fin).min(1.0) } else { 1.0 };
|
||||
let fo = if fout > 0.0 { ((life - age) / fout).min(1.0) } else { 1.0 };
|
||||
let centre = (c.0 + dirn.sin() * vel * age, c.1 - dirn.cos() * vel * age);
|
||||
Some((centre, expansion * age, ((sw + (ew - sw) * age / life) * 0.5).max(0.25), orient + spin * age, (fi * fo) as f32))
|
||||
let dirn = pr.f("waveMotion/direction").to_radians();
|
||||
let vel = pr.f("waveMotion/velocity") * b.scale;
|
||||
let mut centre = (c.0 + dirn.sin() * vel * age, c.1 - dirn.cos() * vel * age);
|
||||
if reflection {
|
||||
centre = (reflect(centre.0, lx, lx + lw), reflect(centre.1, ly, ly + lh));
|
||||
}
|
||||
let sw = pr.f("waveStroke/startWidth") * b.scale;
|
||||
let ew = pr.f("waveStroke/endWidth") * b.scale;
|
||||
let radius = pr.f("waveMotion/expansion") * b.scale * age;
|
||||
let rotation = pr.f("waveMotion/orientation").to_radians() + pr.f("waveMotion/spin").to_radians() * age;
|
||||
let outline = (wave_type == 0).then(|| {
|
||||
let sides = pr.f("polygon/sides").round().clamp(3.0, 64.0);
|
||||
let star = pr.b("polygon/star");
|
||||
let depth = pr.f("polygon/starDepth").clamp(-1.0, 1.0);
|
||||
if sides >= 64.0 && !star {
|
||||
return None;
|
||||
}
|
||||
let n = sides as usize;
|
||||
let unit: Vec<(f64, f64)> = if star {
|
||||
(0..2 * n).map(|i| (PI * i as f64 / n as f64 - PI / 2.0, if i % 2 == 0 { 1.0 } else { (1.0 + depth).max(0.0) })).collect()
|
||||
} else {
|
||||
(0..n).map(|i| (2.0 * PI * i as f64 / n as f64 - PI / 2.0, 1.0)).collect()
|
||||
};
|
||||
Some(unit.iter().map(|&(a, k)| ((a + rotation).cos() * radius * k, (a + rotation).sin() * radius * k)).collect())
|
||||
});
|
||||
Some(Wave {
|
||||
centre,
|
||||
radius,
|
||||
half_width: ((sw + (ew - sw) * age / life) * 0.5).max(0.25),
|
||||
rotation,
|
||||
fade: (fi * fo) as f32,
|
||||
color: pr.color("waveStroke/color"),
|
||||
opacity: (pr.f("waveStroke/opacity") / 100.0) as f32,
|
||||
outline: outline.flatten(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let circle = sides >= 64.0 && !star;
|
||||
// Unit outline (radius 1) of the polygon or star, rotated per wave below.
|
||||
let n = sides as usize;
|
||||
let unit: Vec<(f64, f64)> = if star {
|
||||
(0..2 * n).map(|i| (PI * i as f64 / n as f64 - PI / 2.0, if i % 2 == 0 { 1.0 } else { (1.0 + depth).max(0.0) })).collect()
|
||||
} else {
|
||||
(0..n).map(|i| (2.0 * PI * i as f64 / n as f64 - PI / 2.0, 1.0)).collect()
|
||||
};
|
||||
let outlines: Vec<Vec<(f64, f64)>> =
|
||||
waves.iter().map(|&(_, rad, _, rot, _)| unit.iter().map(|&(a, k)| ((a + rot).cos() * rad * k, (a + rot).sin() * rad * k)).collect()).collect();
|
||||
map_xy(&mut b.img, |x, y, px| {
|
||||
let mut a = 0.0f32;
|
||||
for (wi, &(cc, rad, hw, _, fade)) in waves.iter().enumerate() {
|
||||
let (dx, dy) = (x as f64 + 0.5 - cc.0, y as f64 + 0.5 - cc.1);
|
||||
let d = if circle { ((dx * dx + dy * dy).sqrt() - rad).abs() } else { outline_dist(&outlines[wi], dx, dy) };
|
||||
a = a.max((hw - d + 0.5).clamp(0.0, 1.0) as f32 * fade);
|
||||
let mut out = px;
|
||||
// Older waves first, so newer ones sit on top.
|
||||
for wv in &waves {
|
||||
let (dx, dy) = (x as f64 + 0.5 - wv.centre.0, y as f64 + 0.5 - wv.centre.1);
|
||||
// Signed distance from the wave line (negative inside the wave).
|
||||
let d = match (&contour, &wv.outline) {
|
||||
(Some(ct), _) => {
|
||||
// The contour placed with its anchor on the wave centre, rotated.
|
||||
let (s, c) = (-(wv.rotation)).sin_cos();
|
||||
let (rx, ry) = (dx * c - dy * s, dx * s + dy * c);
|
||||
ct.sdf.sample(ct.anchor.0 + rx, ct.anchor.1 + ry) as f64 - wv.radius
|
||||
}
|
||||
(None, Some(o)) => {
|
||||
let ud = outline_dist(o, dx, dy);
|
||||
let poly: Vec<[f64; 2]> = o.iter().map(|p| [p.0, p.1]).collect();
|
||||
if crate::util::point_in_poly(&poly, dx, dy) { -ud } else { ud }
|
||||
}
|
||||
(None, None) => (dx * dx + dy * dy).sqrt() - wv.radius,
|
||||
};
|
||||
let ad = d.abs();
|
||||
let cov = ((wv.half_width - ad) / aa + 0.5).clamp(0.0, 1.0);
|
||||
if cov <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let u = (d / wv.half_width).clamp(-1.0, 1.0);
|
||||
let a = (cov * rw_profile(profile, u)) as f32 * wv.fade * wv.opacity * wv.color[3];
|
||||
out = over(out, [wv.color[0] * a, wv.color[1] * a, wv.color[2] * a, a]);
|
||||
}
|
||||
let a = a * op * color[3];
|
||||
over(px, [color[0] * a, color[1] * a, color[2] * a, a])
|
||||
out
|
||||
});
|
||||
b
|
||||
}
|
||||
|
|
@ -414,7 +639,40 @@ type Seg = ((f64, f64), (f64, f64), f32);
|
|||
/// Advanced Lightning's Lightning Type options.
|
||||
const LIGHTNING_TYPES: [&str; 8] = ["Direction", "Strike", "Breaking", "Bouncy", "Omni", "Anywhere", "Vertical", "Two-Way Strike"];
|
||||
|
||||
fn bolt(a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, turb: f64, fork: f64, decay: f32, rng: &mut Rng, out: &mut Vec<Seg>) {
|
||||
/// Advanced Lightning's Fractal Type options.
|
||||
const FRACTAL_TYPES: [&str; 3] = ["Linear", "Semi-Linear", "Spline"];
|
||||
|
||||
/// How bolts grow: the general and Expert Settings.
|
||||
struct BoltCfg<'a> {
|
||||
turb: f64,
|
||||
fork: f64,
|
||||
decay: f32,
|
||||
/// The layer's alpha (buffer grid) and Alpha Obstacle (−10..10; positive avoids opaque
|
||||
/// areas, negative is drawn to them).
|
||||
alpha: Option<&'a Plane>,
|
||||
obstacle: f64,
|
||||
main_only: bool,
|
||||
/// Min Fork Distance (px): shorter stretches do not fork.
|
||||
min_fork: f64,
|
||||
/// Termination Threshold: branches fainter than this stop.
|
||||
term: f32,
|
||||
/// Zig-zag scale from Fractal Type (Linear 1, Semi-Linear 0.7, Spline 0.45 with an extra
|
||||
/// subdivision level).
|
||||
shape: f64,
|
||||
fork_strength: f32,
|
||||
fork_var: f32,
|
||||
}
|
||||
|
||||
impl BoltCfg<'_> {
|
||||
fn alpha_at(&self, p: (f64, f64)) -> f32 {
|
||||
self.alpha.map_or(0.0, |a| a.sample(p.0, p.1))
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow a bolt from `a` to `z` by midpoint displacement (`depth` levels), forking at random.
|
||||
/// Segments go to `out` with their intensity; `mains` marks the main core's segments.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, main: bool, rng: &mut Rng, out: &mut Vec<Seg>, mains: &mut Vec<bool>) {
|
||||
if out.len() > 20_000 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -422,19 +680,38 @@ fn bolt(a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, turb: f64, fork: f
|
|||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if depth == 0 || len < 1.0 {
|
||||
out.push((a, z, inten));
|
||||
mains.push(main);
|
||||
return;
|
||||
}
|
||||
let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9));
|
||||
let off = rng.s() * len * 0.22 * turb;
|
||||
let mut off = rng.s() * len * 0.22 * cfg.turb * cfg.shape;
|
||||
// Alpha Obstacle: of the two mirror positions for the midpoint, prefer the one with less
|
||||
// (positive) or more (negative) alpha, the more strongly the larger the setting.
|
||||
if cfg.obstacle != 0.0 && cfg.alpha.is_some() && (main || !cfg.main_only) {
|
||||
let mid = ((a.0 + z.0) * 0.5, (a.1 + z.1) * 0.5);
|
||||
let p1 = (mid.0 + nx * off, mid.1 + ny * off);
|
||||
let p2 = (mid.0 - nx * off, mid.1 - ny * off);
|
||||
let (a1, a2) = (cfg.alpha_at(p1), cfg.alpha_at(p2));
|
||||
let worse = if cfg.obstacle > 0.0 { a1 > a2 } else { a1 < a2 };
|
||||
if worse && rng.f() < cfg.obstacle.abs() / 10.0 {
|
||||
off = -off;
|
||||
}
|
||||
}
|
||||
let m = ((a.0 + z.0) * 0.5 + nx * off, (a.1 + z.1) * 0.5 + ny * off);
|
||||
bolt(a, m, depth - 1, inten, turb, fork, decay, rng, out);
|
||||
bolt(m, z, depth - 1, inten, turb, fork, decay, rng, out);
|
||||
if rng.f() < fork && inten > 0.05 {
|
||||
// Positive obstacles stop branches that run into opaque areas.
|
||||
if cfg.obstacle > 0.0 && !main && cfg.alpha_at(m) > 0.5 && rng.f() < cfg.obstacle / 10.0 {
|
||||
return;
|
||||
}
|
||||
bolt(cfg, a, m, depth - 1, inten, main, rng, out, mains);
|
||||
bolt(cfg, m, z, depth - 1, inten, main, rng, out, mains);
|
||||
if rng.f() < cfg.fork && inten > cfg.term && len >= cfg.min_fork {
|
||||
let ang = rng.s() * 0.7;
|
||||
let (s, c) = ang.sin_cos();
|
||||
let (fx, fy) = ((m.0 - a.0) * c - (m.1 - a.1) * s, (m.0 - a.0) * s + (m.1 - a.1) * c);
|
||||
let k = 1.2;
|
||||
bolt(m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), inten * (1.0 - decay), turb, fork, decay, rng, out);
|
||||
// Fork Strength 50 % keeps the decayed intensity; Fork Variation randomises it.
|
||||
let fi = inten * (1.0 - cfg.decay) * cfg.fork_strength * 2.0 * if cfg.fork_var > 0.0 { 1.0 - cfg.fork_var * rng.f() as f32 } else { 1.0 };
|
||||
bolt(cfg, m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), fi.min(1.0), false, rng, out, mains);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -446,26 +723,49 @@ fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec<Seg> {
|
|||
let turb = ctx.params.f("turbulence").max(0.0);
|
||||
let fork = (ctx.params.f("forking") / 100.0).clamp(0.0, 1.0);
|
||||
let decay = ctx.params.f("decay").clamp(0.0, 1.0) as f32;
|
||||
let depth = ctx.params.f("expertSettings/complexity").round().clamp(1.0, 12.0) as u32;
|
||||
let fractal = ctx.params.e("expertSettings/fractalType");
|
||||
let depth = ctx.params.f("expertSettings/complexity").round().clamp(1.0, 12.0) as u32 + u32::from(fractal == 2);
|
||||
let state = ctx.params.f("conductivityState").floor() as i64 as u64;
|
||||
let mut rng = Rng::new(state ^ ((ctx.seed as u64) << 32));
|
||||
let obstacle = ctx.params.f("alphaObstacle").clamp(-10.0, 10.0);
|
||||
let alpha_plane = (obstacle != 0.0).then(|| Plane::alpha(&b.img));
|
||||
let g = |id: &str, dflt: f64| ctx.params.get(id).map(Value::as_f64).unwrap_or(dflt);
|
||||
let cfg = BoltCfg {
|
||||
turb,
|
||||
fork,
|
||||
decay,
|
||||
alpha: alpha_plane.as_ref(),
|
||||
obstacle,
|
||||
main_only: ctx.params.b("expertSettings/mainCoreCollisionOnly"),
|
||||
min_fork: g("expertSettings/minForkDistance", 0.0).max(0.0) * b.scale,
|
||||
term: (g("expertSettings/terminationThreshold", 5.0) / 100.0).clamp(0.0, 1.0) as f32,
|
||||
shape: match fractal {
|
||||
1 => 0.7,
|
||||
2 => 0.45,
|
||||
_ => 1.0,
|
||||
},
|
||||
fork_strength: (g("expertSettings/forkStrength", 50.0) / 100.0).clamp(0.0, 1.0) as f32,
|
||||
fork_var: (g("expertSettings/forkVariation", 0.0) / 100.0).clamp(0.0, 1.0) as f32,
|
||||
};
|
||||
let mut segs = Vec::new();
|
||||
let mut mains = Vec::new();
|
||||
let (h, w) = (b.img.height as f64, b.img.width as f64);
|
||||
let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, a, z, depth, 1.0, true, rng, &mut segs, &mut mains);
|
||||
// LIGHTNING_TYPES order.
|
||||
match kind {
|
||||
// Direction: travels toward the Direction point and beyond.
|
||||
0 => {
|
||||
let z = (o.0 + (d.0 - o.0) * 3.0, o.1 + (d.1 - o.1) * 3.0);
|
||||
bolt(o, z, depth, 1.0, turb, fork, decay, &mut rng, &mut segs);
|
||||
go(o, z, depth, &cfg, &mut rng);
|
||||
}
|
||||
// Breaking: more branching as the points move apart.
|
||||
2 => {
|
||||
let dist = ((d.0 - o.0).powi(2) + (d.1 - o.1).powi(2)).sqrt();
|
||||
let f = (fork * (0.5 + dist / w.max(h).max(1.0))).min(1.0);
|
||||
bolt(o, d, depth, 1.0, turb, f, decay, &mut rng, &mut segs);
|
||||
go(o, d, depth, &BoltCfg { fork: f, ..cfg }, &mut rng);
|
||||
}
|
||||
// Bouncy: a strike with a stronger zig-zag.
|
||||
3 => bolt(o, d, depth, 1.0, turb * 1.6, fork, decay, &mut rng, &mut segs),
|
||||
3 => go(o, d, depth, &BoltCfg { turb: turb * 1.6, ..cfg }, &mut rng),
|
||||
// Omni / Anywhere: bolts in all directions out to the Outer Radius.
|
||||
4 | 5 => {
|
||||
let len = ((d.0 - o.0).powi(2) + (d.1 - o.1).powi(2)).sqrt().max(w.min(h) * 0.25);
|
||||
|
|
@ -473,27 +773,37 @@ fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec<Seg> {
|
|||
let a = rng.f() * 2.0 * PI;
|
||||
let l = if kind == 5 { len * (0.3 + 0.7 * rng.f()) } else { len };
|
||||
let z = (o.0 + a.cos() * l, o.1 + a.sin() * l);
|
||||
bolt(o, z, depth, 1.0, turb, fork, decay, &mut rng, &mut segs);
|
||||
go(o, z, depth, &cfg, &mut rng);
|
||||
}
|
||||
}
|
||||
6 => bolt(o, (o.0, h), depth, 1.0, turb, fork, decay, &mut rng, &mut segs),
|
||||
6 => go(o, (o.0, h), depth, &cfg, &mut rng),
|
||||
// Two-Way Strike: from both ends toward the middle.
|
||||
7 => {
|
||||
let m = ((o.0 + d.0) * 0.5, (o.1 + d.1) * 0.5);
|
||||
bolt(o, m, depth.saturating_sub(1).max(1), 1.0, turb, fork, decay, &mut rng, &mut segs);
|
||||
bolt(d, m, depth.saturating_sub(1).max(1), 1.0, turb, fork, decay, &mut rng, &mut segs);
|
||||
go(o, m, depth.saturating_sub(1).max(1), &cfg, &mut rng);
|
||||
go(d, m, depth.saturating_sub(1).max(1), &cfg, &mut rng);
|
||||
}
|
||||
// Strike.
|
||||
_ => bolt(o, d, depth, 1.0, turb, fork, decay, &mut rng, &mut segs),
|
||||
_ => go(o, d, depth, &cfg, &mut rng),
|
||||
}
|
||||
let dist = |p: (f64, f64)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt();
|
||||
let far = segs.iter().map(|(_, z, _)| dist(*z)).fold(1e-9, f64::max);
|
||||
// Decay Main Core: the whole bolt fades with distance from the origin.
|
||||
if decay_main {
|
||||
let far = segs.iter().map(|(_, z, _)| ((z.0 - o.0).powi(2) + (z.1 - o.1).powi(2)).sqrt()).fold(1e-9, f64::max);
|
||||
for (a, _, i) in &mut segs {
|
||||
let t = (((a.0 - o.0).powi(2) + (a.1 - o.1).powi(2)).sqrt() / far) as f32;
|
||||
let t = (dist(*a) / far) as f32;
|
||||
*i *= (1.0 - decay * t).max(0.0);
|
||||
}
|
||||
}
|
||||
// Core Drain: the main core loses intensity along its length.
|
||||
let drain = (g("expertSettings/coreDrain", 0.0) / 100.0).clamp(0.0, 1.0) as f32;
|
||||
if drain > 0.0 {
|
||||
for ((a, _, i), m) in segs.iter_mut().zip(&mains) {
|
||||
if *m {
|
||||
*i *= (1.0 - drain * (dist(*a) / far) as f32).max(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
segs
|
||||
}
|
||||
|
||||
|
|
@ -776,9 +1086,21 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
"Radio Waves",
|
||||
vec![
|
||||
p("producerPoint", "Producer Point", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("parametersAreSetAt", "Parameters Are Set At", Value::Enum(1), popup(&["Birth", "Each Frame"])),
|
||||
p("renderQuality", "Render Quality", num(4.0), slider(1.0, 10.0, 1.0, 10.0, 0)),
|
||||
p("waveType", "Wave Type", Value::Enum(0), popup(&RW_TYPES)),
|
||||
p("polygon/sides", "Sides", num(64.0), slider(3.0, 64.0, 3.0, 64.0, 0)),
|
||||
p("polygon/star", "Star", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("polygon/starDepth", "Star Depth", num(-0.3), slider(-1.0, 1.0, -1.0, 1.0, 2)),
|
||||
p("imageContour/sourceLayer", "Source Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("imageContour/sourceCenter", "Source Center", pt(0.5, 0.5), ParamUi::Point),
|
||||
p("imageContour/valueChannel", "Value Channel", Value::Enum(4), popup(&RW_CHANNELS)),
|
||||
p("imageContour/invertInput", "Invert Input", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("imageContour/valueThreshold", "Value Threshold", num(128.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
|
||||
p("imageContour/preBlur", "Pre-Blur", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("imageContour/tolerance", "Tolerance", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("imageContour/contour", "Contour", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 0)),
|
||||
p("waveMask/mask", "Mask", num(0.0), ParamUi::Mask),
|
||||
p("waveMotion/frequency", "Frequency", num(1.0), slider(0.01, 100.0, 0.01, 10.0, 2)),
|
||||
p("waveMotion/expansion", "Expansion", num(100.0), slider(0.0, 10000.0, 0.0, 1000.0, 1)),
|
||||
p("waveMotion/orientation", "Orientation", num(0.0), ang()),
|
||||
|
|
@ -786,6 +1108,8 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("waveMotion/velocity", "Velocity", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 1)),
|
||||
p("waveMotion/spin", "Spin", num(0.0), slider(-3600.0, 3600.0, -360.0, 360.0, 1)),
|
||||
p("waveMotion/lifespan", "Lifespan (sec)", num(2.0), slider(0.01, 100.0, 0.01, 10.0, 2)),
|
||||
p("waveMotion/reflection", "Reflection", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("waveStroke/profile", "Profile", Value::Enum(0), popup(&RW_PROFILES)),
|
||||
p("waveStroke/color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("waveStroke/opacity", "Opacity", num(100.0), pct()),
|
||||
p("waveStroke/fadeInTime", "Fade-in Time", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
|
||||
|
|
@ -813,8 +1137,16 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("forking", "Forking", num(25.0), pct()),
|
||||
p("decay", "Decay", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("decayMainCore", "Decay Main Core", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("alphaObstacle", "Alpha Obstacle", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 2)),
|
||||
p("compositeOnOriginal", "Composite On Original", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("expertSettings/complexity", "Complexity", num(6.0), slider(1.0, 12.0, 1.0, 12.0, 0)),
|
||||
p("expertSettings/minForkDistance", "Min Fork Distance", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||
p("expertSettings/terminationThreshold", "Termination Threshold", num(5.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("expertSettings/mainCoreCollisionOnly", "Main Core Collision Only", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("expertSettings/fractalType", "Fractal Type", Value::Enum(0), popup(&FRACTAL_TYPES)),
|
||||
p("expertSettings/coreDrain", "Core Drain", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("expertSettings/forkStrength", "Fork Strength", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("expertSettings/forkVariation", "Fork Variation", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
],
|
||||
advanced_lightning,
|
||||
),
|
||||
|
|
@ -972,6 +1304,43 @@ mod tests {
|
|||
assert!(o.img.get(46, 18)[3] < 0.01, "no circle there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_alpha_obstacle_and_expert_settings() {
|
||||
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::<f32>();
|
||||
// A wall of opaque pixels across the middle of the bolt's path.
|
||||
let mut wall = Image::new(64, 48);
|
||||
for y in 0..48 {
|
||||
for x in 0..64 {
|
||||
if (18..30).contains(&y) && x < 50 {
|
||||
wall.set(x, y, [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let opts = |o: f64| {
|
||||
vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))]
|
||||
};
|
||||
let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone());
|
||||
let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone());
|
||||
assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall));
|
||||
// Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance.
|
||||
let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
|
||||
for (id, v) in [
|
||||
("expertSettings/fractalType", Value::Enum(2)),
|
||||
("expertSettings/coreDrain", num(100.0)),
|
||||
("expertSettings/forkStrength", num(100.0)),
|
||||
("expertSettings/forkVariation", num(100.0)),
|
||||
] {
|
||||
assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}");
|
||||
}
|
||||
let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48));
|
||||
assert!(sum(&drained.img) < sum(&base.img));
|
||||
let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48));
|
||||
let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48));
|
||||
assert_eq!(no_forks.img, term.img, "no forks either way");
|
||||
assert!(sum(&no_forks.img) < sum(&base.img));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_types_and_decay_main_core() {
|
||||
let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48));
|
||||
|
|
@ -1014,4 +1383,130 @@ mod tests {
|
|||
assert!(c > o.img.get(60, 4)[0]);
|
||||
assert!(c > 0.5);
|
||||
}
|
||||
|
||||
fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image {
|
||||
let mut v: Vec<(&str, Value)> =
|
||||
vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))];
|
||||
v.extend_from_slice(set);
|
||||
crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_distance_of_a_square() {
|
||||
let (w, h) = (20, 20);
|
||||
let inside: Vec<bool> = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect();
|
||||
let d = crate::util::signed_distance(&inside, w, h);
|
||||
assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10));
|
||||
assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10));
|
||||
assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_waves_mask_contours_profile_reflection_and_birth() {
|
||||
// Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it.
|
||||
let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false };
|
||||
let masks = [sq];
|
||||
let env = crate::EffectEnv { masks: &masks, ..Default::default() };
|
||||
let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env);
|
||||
assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32));
|
||||
assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01);
|
||||
// Rounded corner: diagonal distance 10 from (37, 37).
|
||||
assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44));
|
||||
// No mask chosen: nothing drawn.
|
||||
assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0));
|
||||
// Image Contours: the bright square of a source layer.
|
||||
struct Src;
|
||||
impl crate::EffectHost for Src {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
let mut img = Image::new(64, 64);
|
||||
for y in 27..37 {
|
||||
for x in 27..37 {
|
||||
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] })
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn params_at(&self, t: f64) -> Option<Params> {
|
||||
// Colour animates from red (born at 0) to green (born later).
|
||||
let s = crate::find("ec.generate.radiowaves").unwrap();
|
||||
let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0]));
|
||||
p.values.insert("waveMotion/frequency".into(), num(1.0));
|
||||
p.values.insert("waveMotion/expansion".into(), num(20.0));
|
||||
p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) });
|
||||
Some(p)
|
||||
}
|
||||
}
|
||||
let env = crate::EffectEnv { host: Some(&Src), ..Default::default() };
|
||||
let ic = rw(
|
||||
&[
|
||||
("waveType", Value::Enum(1)),
|
||||
("imageContour/sourceLayer", Value::Layer(Some(1))),
|
||||
("imageContour/preBlur", num(0.0)),
|
||||
("imageContour/tolerance", num(0.0)),
|
||||
],
|
||||
0.1,
|
||||
env,
|
||||
);
|
||||
assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32));
|
||||
// Source Center off the square shifts the contour onto the producer point accordingly.
|
||||
let sh = rw(
|
||||
&[
|
||||
("waveType", Value::Enum(1)),
|
||||
("imageContour/sourceLayer", Value::Layer(Some(1))),
|
||||
("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])),
|
||||
("imageContour/preBlur", num(0.0)),
|
||||
("imageContour/tolerance", num(0.0)),
|
||||
],
|
||||
0.1,
|
||||
env,
|
||||
);
|
||||
assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32));
|
||||
// Profile: Taper Both thins the stroke edges; Square is solid across.
|
||||
let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))];
|
||||
let sqr = rw(&wide, 0.2, crate::EffectEnv::default());
|
||||
let mut tap = wide.to_vec();
|
||||
tap.push(("waveStroke/profile", Value::Enum(3)));
|
||||
let tap = rw(&tap, 0.2, crate::EffectEnv::default());
|
||||
assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32));
|
||||
assert!(tap.get(52, 32)[3] > 0.8);
|
||||
// Reflection: a fast wave bounces back off the right edge.
|
||||
let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))];
|
||||
let gone = rw(&fast, 0.3, crate::EffectEnv::default());
|
||||
let mut rf = fast.to_vec();
|
||||
rf.push(("waveMotion/reflection", Value::Bool(true)));
|
||||
let back = rw(&rf, 0.3, crate::EffectEnv::default());
|
||||
let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone));
|
||||
// Parameters Are Set At Birth: the older wave keeps red, the newer one is green.
|
||||
let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env);
|
||||
let (old, new) = (both.get(55, 32), both.get(35, 32));
|
||||
assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}");
|
||||
assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_pattern_hq_variants_antialias() {
|
||||
let s = crate::find("ec.generate.cellpattern").unwrap();
|
||||
let r = |pat: u32| {
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
params.values.insert("cellPattern".into(), Value::Enum(pat));
|
||||
params.values.insert("size".into(), num(12.0));
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() };
|
||||
crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img
|
||||
};
|
||||
let distinct = |img: &Image| {
|
||||
let mut v: Vec<u32> = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v.len()
|
||||
};
|
||||
let (std, hq) = (r(4), r(9));
|
||||
assert_eq!(CELL_PATTERNS[9], "Crystallize HQ");
|
||||
assert_ne!(std, hq);
|
||||
assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -466,6 +466,141 @@ fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64;
|
|||
*out = merged;
|
||||
}
|
||||
|
||||
/// End Cap and Join options (Scribble's Edge Options).
|
||||
const END_CAPS: [&str; 3] = ["Round", "Butt", "Projecting"];
|
||||
const JOINS: [&str; 3] = ["Round", "Miter", "Bevel"];
|
||||
|
||||
/// Convex polygons whose union is the stroke of the polylines `lines` with half width `r`:
|
||||
/// one quad per segment, plus the joins (Round discs, Miter wedges up to `miter_limit` × the
|
||||
/// half width, Bevel triangles) and the caps (Round discs, Butt none, Projecting square ends).
|
||||
pub(crate) fn stroke_outline(lines: &[Vec<[f64; 2]>], r: f64, cap: u32, join: u32, miter_limit: f64) -> Vec<Vec<[f64; 2]>> {
|
||||
let disc = |c: [f64; 2]| -> Vec<[f64; 2]> {
|
||||
(0..16).map(|k| (k as f64 / 16.0 * std::f64::consts::TAU).sin_cos()).map(|(s, co)| [c[0] + co * r, c[1] + s * r]).collect()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for l in lines {
|
||||
let pts: Vec<[f64; 2]> = l.iter().copied().fold(Vec::new(), |mut v, p| {
|
||||
if v.last().is_none_or(|q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) > 1e-9) {
|
||||
v.push(p);
|
||||
}
|
||||
v
|
||||
});
|
||||
if pts.len() < 2 {
|
||||
if let (Some(p), 0) = (pts.first(), cap) {
|
||||
out.push(disc(*p));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let dir = |a: [f64; 2], b: [f64; 2]| {
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l = dx.hypot(dy).max(1e-12);
|
||||
[dx / l, dy / l]
|
||||
};
|
||||
let n = pts.len();
|
||||
for k in 0..n - 1 {
|
||||
let (mut a, mut b) = (pts[k], pts[k + 1]);
|
||||
let d = dir(a, b);
|
||||
// Projecting caps extend the end segments by the half width.
|
||||
if cap == 2 && k == 0 {
|
||||
a = [a[0] - d[0] * r, a[1] - d[1] * r];
|
||||
}
|
||||
if cap == 2 && k == n - 2 {
|
||||
b = [b[0] + d[0] * r, b[1] + d[1] * r];
|
||||
}
|
||||
let nn = [-d[1] * r, d[0] * r];
|
||||
out.push(vec![[a[0] + nn[0], a[1] + nn[1]], [b[0] + nn[0], b[1] + nn[1]], [b[0] - nn[0], b[1] - nn[1]], [a[0] - nn[0], a[1] - nn[1]]]);
|
||||
}
|
||||
// Joins.
|
||||
for k in 1..n - 1 {
|
||||
let (p, d0, d1) = (pts[k], dir(pts[k - 1], pts[k]), dir(pts[k], pts[k + 1]));
|
||||
let cross = d0[0] * d1[1] - d0[1] * d1[0];
|
||||
if cross.abs() < 1e-9 && d0[0] * d1[0] + d0[1] * d1[1] > 0.0 {
|
||||
continue;
|
||||
}
|
||||
if join == 0 {
|
||||
out.push(disc(p));
|
||||
continue;
|
||||
}
|
||||
// The outer side of the turn.
|
||||
let s = if cross > 0.0 { -1.0 } else { 1.0 };
|
||||
let o0 = [p[0] - d0[1] * r * s, p[1] + d0[0] * r * s];
|
||||
let o1 = [p[0] - d1[1] * r * s, p[1] + d1[0] * r * s];
|
||||
let bevel = vec![p, o0, o1];
|
||||
if join == 1 {
|
||||
// Miter point: where the two offset edges meet.
|
||||
let bis = [d0[0] - d1[0], d0[1] - d1[1]];
|
||||
let bl = bis[0].hypot(bis[1]);
|
||||
let cos_half = ((1.0 + d0[0] * d1[0] + d0[1] * d1[1]) * 0.5).max(0.0).sqrt();
|
||||
let len = if cos_half > 1e-6 { r / cos_half } else { f64::INFINITY };
|
||||
if bl > 1e-9 && len <= miter_limit * r {
|
||||
let m = [p[0] + (o0[0] + o1[0] - 2.0 * p[0]) * 0.5, p[1] + (o0[1] + o1[1] - 2.0 * p[1]) * 0.5];
|
||||
let ml = (m[0] - p[0]).hypot(m[1] - p[1]).max(1e-12);
|
||||
let tip = [p[0] + (m[0] - p[0]) / ml * len, p[1] + (m[1] - p[1]) / ml * len];
|
||||
out.push(vec![p, o0, tip, o1]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bevel);
|
||||
}
|
||||
if cap == 0 {
|
||||
out.push(disc(pts[0]));
|
||||
out.push(disc(pts[n - 1]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Coverage (union, 4×4 samples per pixel) of convex polygons on a `w`×`h` grid.
|
||||
pub(crate) fn raster_convex(w: usize, h: usize, polys: &[Vec<[f64; 2]>]) -> Plane {
|
||||
let mut out = Plane::new(w, h);
|
||||
let inside = |poly: &[[f64; 2]], x: f64, y: f64| {
|
||||
let n = poly.len();
|
||||
let mut sign = 0.0;
|
||||
for i in 0..n {
|
||||
let (a, b) = (poly[i], poly[(i + 1) % n]);
|
||||
let c = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]);
|
||||
if c.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
if sign == 0.0 {
|
||||
sign = c.signum();
|
||||
} else if c.signum() != sign {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
};
|
||||
for poly in polys {
|
||||
if poly.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for p in poly {
|
||||
x0 = x0.min(p[0]);
|
||||
y0 = y0.min(p[1]);
|
||||
x1 = x1.max(p[0]);
|
||||
y1 = y1.max(p[1]);
|
||||
}
|
||||
let (xa, xb) = (x0.floor().max(0.0) as usize, (x1.ceil().max(0.0) as usize).min(w));
|
||||
let (ya, yb) = (y0.floor().max(0.0) as usize, (y1.ceil().max(0.0) as usize).min(h));
|
||||
for y in ya..yb {
|
||||
for x in xa..xb {
|
||||
let mut hit = 0;
|
||||
for sy in 0..4 {
|
||||
for sx in 0..4 {
|
||||
if inside(poly, x as f64 + (sx as f64 + 0.5) / 4.0, y as f64 + (sy as f64 + 0.5) / 4.0) {
|
||||
hit += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let v = &mut out.data[y * w + x];
|
||||
*v = v.max(hit as f32 / 16.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let which = pr.e("scribble");
|
||||
|
|
@ -585,19 +720,33 @@ fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect());
|
||||
}
|
||||
let r = width * 0.5;
|
||||
let mut segs = Vec::new();
|
||||
// Start / End Apply To: all of a mask's strokes as one path, or each stroke on its own.
|
||||
let each = pr.e("startEndApplyTo") == 1;
|
||||
let mut parts: Vec<Vec<[f64; 2]>> = Vec::new();
|
||||
for group in &lines {
|
||||
let lens: Vec<f64> = group.iter().map(|l| poly_length(l, false)).collect();
|
||||
let total: f64 = lens.iter().sum();
|
||||
let (g0, g1) = (total * start, total * end);
|
||||
let mut acc = 0.0;
|
||||
for (l, len) in group.iter().zip(lens) {
|
||||
let part = trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len));
|
||||
let part =
|
||||
if each { trim_poly(l, false, len * start, len * end) } else { trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) };
|
||||
acc += len;
|
||||
poly_segs(&part, false, r, 1.0, &mut segs);
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
let cov = raster_segs(w, h, &segs, 0.9);
|
||||
let cap = pr.e("edgeOptions/endCap");
|
||||
let join = pr.e("edgeOptions/join");
|
||||
let cov = if cap == 0 && join == 0 {
|
||||
let mut segs = Vec::new();
|
||||
for part in &parts {
|
||||
poly_segs(part, false, r, 1.0, &mut segs);
|
||||
}
|
||||
raster_segs(w, h, &segs, 0.9)
|
||||
} else {
|
||||
let polys = stroke_outline(&parts, r, cap, join, pr.f("edgeOptions/miterLimit").max(1.0));
|
||||
raster_convex(w, h, &polys)
|
||||
};
|
||||
let color = pr.color("color");
|
||||
paint(&mut b.img, &cov, color, (pr.f("opacity") / 100.0) as f32, style);
|
||||
b
|
||||
|
|
@ -1467,6 +1616,9 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
mask_idx("mask", "Mask", 1.0),
|
||||
p("fillType", "Fill Type", Value::Enum(0), popup(&["Inside", "Centered Edge", "Inside Edge", "Outside Edge", "Left Edge", "Right Edge"])),
|
||||
p("edgeOptions/edgeWidth", "Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||
p("edgeOptions/endCap", "End Cap", Value::Enum(0), popup(&END_CAPS)),
|
||||
p("edgeOptions/join", "Join", Value::Enum(0), popup(&JOINS)),
|
||||
p("edgeOptions/miterLimit", "Miter Limit", num(4.0), slider(1.0, 100.0, 1.0, 20.0, 1)),
|
||||
p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color),
|
||||
p("opacity", "Opacity", num(100.0), pct()),
|
||||
p("angle", "Angle", num(45.0), ang()),
|
||||
|
|
@ -1479,6 +1631,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("strokeOptions/pathOverlapVariation", "Path Overlap Variation", num(50.0), pct()),
|
||||
p("start", "Start", num(0.0), pct()),
|
||||
p("end", "End", num(100.0), pct()),
|
||||
p("startEndApplyTo", "Start/End Apply To", Value::Enum(0), popup(&["All Strokes", "Each Stroke"])),
|
||||
check("fillPathsSequentially", "Fill Paths Sequentially", true),
|
||||
p("wiggleType", "Wiggle Type", Value::Enum(1), popup(&["Static", "Jumpy", "Smooth"])),
|
||||
p("wigglesPerSecond", "Wiggles/Second", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)),
|
||||
|
|
@ -1936,4 +2089,49 @@ mod tests {
|
|||
let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0;
|
||||
assert_eq!(peak, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_outline_caps_and_joins() {
|
||||
let line = vec![vec![[10.0, 10.0], [30.0, 10.0]]];
|
||||
let cov = |cap: u32| raster_convex(40, 20, &stroke_outline(&line, 3.0, cap, 0, 4.0));
|
||||
// Round caps reach 3 px past the ends (on the axis), Butt stops there, Projecting is square.
|
||||
let (round, butt, proj) = (cov(0), cov(1), cov(2));
|
||||
assert!(round.get(8, 10) > 0.9 && butt.get(8, 10) < 0.1 && proj.get(8, 10) > 0.9);
|
||||
assert!(proj.get(7, 7) > 0.9 && round.get(7, 7) < 0.5, "square corner");
|
||||
// A right-angle turn: Miter fills the outer corner, Bevel cuts it.
|
||||
let corner = vec![vec![[5.0, 30.0], [30.0, 30.0], [30.0, 5.0]]];
|
||||
let j = |join: u32| raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, join, 4.0));
|
||||
let (miter, bevel) = (j(1), j(2));
|
||||
assert!(miter.get(32, 32) > 0.9 && bevel.get(32, 32) < 0.1, "{} {}", miter.get(32, 32), bevel.get(32, 32));
|
||||
// A tight miter limit falls back to a bevel.
|
||||
let limited = raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, 1, 1.0));
|
||||
assert!(limited.get(32, 32) < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scribble_caps_and_start_end_apply_to() {
|
||||
let masks = square_mask();
|
||||
let env = EffectEnv { masks: &masks, ..Default::default() };
|
||||
let img = Image::new(64, 64);
|
||||
let base = [("wiggleType", Value::Enum(0)), ("strokeWidth", num(3.0))];
|
||||
let round = run_env("ec.generate.scribble", &base, img.clone(), 0.0, env);
|
||||
let butt = run_env(
|
||||
"ec.generate.scribble",
|
||||
&[base.as_slice(), &[("edgeOptions/endCap", Value::Enum(1)), ("edgeOptions/join", Value::Enum(2))]].concat(),
|
||||
img.clone(),
|
||||
0.0,
|
||||
env,
|
||||
);
|
||||
assert_ne!(round.img.data, butt.img.data);
|
||||
// Each Stroke: every stroke is trimmed on its own, so the half-way image has ink all over.
|
||||
let two = vec![
|
||||
crate::MaskShape { name: String::new(), points: vec![[4.0, 4.0], [28.0, 4.0], [28.0, 60.0], [4.0, 60.0]], closed: true, inverted: false },
|
||||
crate::MaskShape { name: String::new(), points: vec![[36.0, 4.0], [60.0, 4.0], [60.0, 60.0], [36.0, 60.0]], closed: true, inverted: false },
|
||||
];
|
||||
let env2 = EffectEnv { masks: &two, ..Default::default() };
|
||||
let seq = [("scribble", Value::Enum(1)), ("end", num(50.0))];
|
||||
let all = run_env("ec.generate.scribble", &[base.as_slice(), &seq].concat(), img.clone(), 0.0, env2);
|
||||
let each = run_env("ec.generate.scribble", &[base.as_slice(), &seq, &[("startEndApplyTo", Value::Enum(1))]].concat(), img, 0.0, env2);
|
||||
assert_ne!(all.img.data, each.img.data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -496,14 +496,36 @@ fn advanced_spill(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let amt = ctx.params.f("suppression") as f32 / 100.0;
|
||||
let range = ctx.params.f("ultraSettings/spillRange") as f32 / 100.0;
|
||||
let luma = ctx.params.f("ultraSettings/lumaCorrection") as f32 / 100.0;
|
||||
// Ultra: Tolerance limits suppression to hues near the key colour; Desaturate takes the
|
||||
// colour out of suppressed pixels; Spill Color Correction puts the removed spill back as
|
||||
// neutral light (so suppressed areas keep their brightness without the cast).
|
||||
let key = ctx.params.color("ultraSettings/keyColor");
|
||||
let key_hue = effectcraft_color::rgb_to_hsl(key[0], key[1], key[2]).0;
|
||||
let tol = if ultra { ctx.params.get("ultraSettings/tolerance").map(Value::as_f64).unwrap_or(100.0) as f32 / 100.0 } else { 1.0 };
|
||||
let desat = if ultra { ctx.params.f("ultraSettings/desaturate") as f32 / 100.0 } else { 0.0 };
|
||||
let neutral = if ultra { ctx.params.f("ultraSettings/spillColorCorrection") as f32 / 100.0 } else { 0.0 };
|
||||
b.img.map_straight(|mut c| {
|
||||
let w = if tol < 1.0 {
|
||||
let h = effectcraft_color::rgb_to_hsl(c[0], c[1], c[2]).0;
|
||||
let d = (h - key_hue).abs();
|
||||
let d = d.min(1.0 - d);
|
||||
(1.0 - d / (tol * 0.5).max(1e-4)).clamp(0.0, 1.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let mx = c[o1].max(c[o2]);
|
||||
let avg = 0.5 * (c[o1] + c[o2]);
|
||||
let limit = mx + (avg - mx) * range;
|
||||
let spill = (c[pi] - limit).max(0.0) * amt;
|
||||
let spill = (c[pi] - limit).max(0.0) * amt * w;
|
||||
c[pi] -= spill;
|
||||
let back = spill * LUMA_W[pi] * luma;
|
||||
c.map(|v| v + back)
|
||||
let back = spill * LUMA_W[pi] * luma + spill * neutral / 3.0;
|
||||
let mut c = c.map(|v| v + back);
|
||||
if desat > 0.0 && spill > 0.0 {
|
||||
let l = effectcraft_color::luminance(c[0], c[1], c[2]);
|
||||
let k = (desat * (spill * 4.0).min(1.0)).min(1.0);
|
||||
c = c.map(|v| v + (l - v) * k);
|
||||
}
|
||||
c
|
||||
});
|
||||
b
|
||||
}
|
||||
|
|
@ -675,7 +697,10 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("method", "Method", Value::Enum(0), popup(&["Standard", "Ultra"])),
|
||||
p("suppression", "Suppression", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 0)),
|
||||
p("ultraSettings/keyColor", "Key Color", col(0.0, 1.0, 0.0), ParamUi::Color),
|
||||
p("ultraSettings/tolerance", "Tolerance", num(50.0), pct()),
|
||||
p("ultraSettings/desaturate", "Desaturate", num(0.0), pct()),
|
||||
p("ultraSettings/spillRange", "Spill Range", num(50.0), pct()),
|
||||
p("ultraSettings/spillColorCorrection", "Spill Color Correction", num(0.0), pct()),
|
||||
p("ultraSettings/lumaCorrection", "Luma Correction", num(0.0), pct()),
|
||||
],
|
||||
advanced_spill,
|
||||
|
|
@ -846,4 +871,26 @@ mod tests {
|
|||
let out = run("ec.key.keycleaner", &[], img.clone());
|
||||
assert_eq!(out.get(12, 2), img.get(12, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_spill_ultra_tolerance_desaturate_and_color_correction() {
|
||||
let ultra = |extra: &[(&str, Value)], c: [f32; 4]| {
|
||||
let mut v = vec![("method", Value::Enum(1)), ("ultraSettings/keyColor", Value::Color([0.0, 1.0, 0.0, 1.0]))];
|
||||
v.extend_from_slice(extra);
|
||||
run("ec.key.advancedspill", &v, Image::filled(1, 1, c)).get(0, 0)
|
||||
};
|
||||
// A yellow-green pixel (hue away from the key) is left alone with a narrow tolerance.
|
||||
let yg = [0.6, 0.8, 0.1, 1.0];
|
||||
assert!((ultra(&[("ultraSettings/tolerance", num(5.0))], yg)[1] - 0.8).abs() < 1e-5);
|
||||
assert!(ultra(&[("ultraSettings/tolerance", num(100.0))], yg)[1] < 0.79);
|
||||
// Desaturate greys the suppressed pixel.
|
||||
let g = [0.3, 0.8, 0.3, 1.0];
|
||||
let d = ultra(&[("ultraSettings/desaturate", num(100.0))], g);
|
||||
assert!((d[0] - d[1]).abs() < 0.05, "{d:?}");
|
||||
// Spill Color Correction keeps the brightness the suppression removed.
|
||||
let plain = ultra(&[], g);
|
||||
let cc = ultra(&[("ultraSettings/spillColorCorrection", num(100.0))], g);
|
||||
let sum = |p: [f32; 4]| p[0] + p[1] + p[2];
|
||||
assert!(sum(cc) > sum(plain) + 0.1 && (sum(cc) - sum(g)).abs() < 1e-4, "{plain:?} {cc:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,8 +102,11 @@ fn mask_sdf(ctx: &EffectCtx, b: &Buf, idx: &[u32]) -> Option<Plane> {
|
|||
}
|
||||
|
||||
fn inner_outer_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let fg_idx = [ctx.params.e("foreground"), ctx.params.e("additionalForeground")];
|
||||
let bg_idx = [ctx.params.e("background"), ctx.params.e("additionalBackground")];
|
||||
// Foreground (Inside) plus Additional Foreground 1–10; likewise for the background.
|
||||
let fg_idx: Vec<u32> =
|
||||
std::iter::once(ctx.params.e("foreground")).chain((1..=10).map(|k| ctx.params.e(&format!("additionalForeground/foreground{k}")))).collect();
|
||||
let bg_idx: Vec<u32> =
|
||||
std::iter::once(ctx.params.e("background")).chain((1..=10).map(|k| ctx.params.e(&format!("additionalBackground/background{k}")))).collect();
|
||||
let radius = (ctx.params.f("singleMaskHighlightRadius") * b.scale) as f32;
|
||||
let thin = ctx.params.f("edgeThin") * b.scale;
|
||||
let feather = ctx.params.f("edgeFeather") * b.scale;
|
||||
|
|
@ -187,6 +190,26 @@ fn inner_outer_key(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
.collect(),
|
||||
);
|
||||
}
|
||||
// Cleanup Foreground / Background: brush strokes along mask paths that paint the matte
|
||||
// towards opaque / transparent (Brush Radius, Brush Pressure).
|
||||
for (side, target) in [("Foreground", 1.0f32), ("Background", 0.0)] {
|
||||
for k in 1..=10 {
|
||||
let g = format!("cleanup{side}/cleanup{side}{k}");
|
||||
let idx = ctx.params.e(&format!("{g}/path"));
|
||||
let Some(m) = (idx > 0).then(|| ctx.env.masks.get(idx as usize - 1)).flatten() else { continue };
|
||||
let rad = (ctx.params.f(&format!("{g}/brushRadius")) * b.scale).max(0.5);
|
||||
let pressure = (ctx.params.f(&format!("{g}/brushPressure")) / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let pts: Vec<[f64; 2]> = m.points.iter().map(|q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect();
|
||||
alpha.data.par_iter_mut().enumerate().for_each(|(i, a)| {
|
||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
let d = dist_to_poly(&pts, m.closed, x, y);
|
||||
if d < rad + 1.0 {
|
||||
let cov = ((rad - d) / rad.max(1.0) * 2.0).clamp(0.0, 1.0) as f32 * pressure;
|
||||
*a += (target - *a) * cov;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if thin.abs() > 0.01 {
|
||||
alpha = morph_frac(&alpha, thin.abs(), thin < 0.0);
|
||||
}
|
||||
|
|
@ -332,18 +355,34 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
spec(
|
||||
"ec.key.innerouter",
|
||||
"Inner/Outer Key",
|
||||
vec![
|
||||
p("foreground", "Foreground (Inside)", Value::Enum(0), mask_options()),
|
||||
p("additionalForeground", "Additional Foreground", Value::Enum(0), mask_options()),
|
||||
p("background", "Background (Outside)", Value::Enum(0), mask_options()),
|
||||
p("additionalBackground", "Additional Background", Value::Enum(0), mask_options()),
|
||||
p("singleMaskHighlightRadius", "Single Mask Highlight Radius", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("edgeThin", "Edge Thin", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 1)),
|
||||
p("edgeFeather", "Edge Feather", num(0.0), slider(0.0, 100.0, 0.0, 50.0, 1)),
|
||||
p("edgeThreshold", "Edge Threshold", num(0.0), pct()),
|
||||
p("invertExtraction", "Invert Extraction", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("blendWithOriginal", "Blend with Original", num(0.0), pct()),
|
||||
],
|
||||
{
|
||||
let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) };
|
||||
let mut v = vec![p("foreground", "Foreground (Inside)", Value::Enum(0), mask_options())];
|
||||
for k in 1..=10 {
|
||||
v.push(p(leak(format!("additionalForeground/foreground{k}")), leak(format!("Foreground {k}")), Value::Enum(0), mask_options()));
|
||||
}
|
||||
v.push(p("background", "Background (Outside)", Value::Enum(0), mask_options()));
|
||||
for k in 1..=10 {
|
||||
v.push(p(leak(format!("additionalBackground/background{k}")), leak(format!("Background {k}")), Value::Enum(0), mask_options()));
|
||||
}
|
||||
for side in ["Foreground", "Background"] {
|
||||
for k in 1..=10 {
|
||||
let g = format!("cleanup{side}/cleanup{side}{k}");
|
||||
v.push(p(leak(format!("{g}/path")), "Path", Value::Enum(0), mask_options()));
|
||||
v.push(p(leak(format!("{g}/brushRadius")), "Brush Radius", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)));
|
||||
v.push(p(leak(format!("{g}/brushPressure")), "Brush Pressure", num(80.0), pct()));
|
||||
}
|
||||
}
|
||||
v.extend([
|
||||
p("singleMaskHighlightRadius", "Single Mask Highlight Radius", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("edgeThin", "Edge Thin", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 1)),
|
||||
p("edgeFeather", "Edge Feather", num(0.0), slider(0.0, 100.0, 0.0, 50.0, 1)),
|
||||
p("edgeThreshold", "Edge Threshold", num(0.0), pct()),
|
||||
p("invertExtraction", "Invert Extraction", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("blendWithOriginal", "Blend with Original", num(0.0), pct()),
|
||||
]);
|
||||
v
|
||||
},
|
||||
inner_outer_key,
|
||||
),
|
||||
spec(
|
||||
|
|
@ -478,4 +517,47 @@ mod tests {
|
|||
assert_eq!(out.img.get(2, 10), img.get(2, 10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_outer_key_more_masks_and_cleanup_strokes() {
|
||||
let sq = |x0: f64, x1: f64| MaskShape { name: String::new(), points: vec![[x0, 2.0], [x1, 2.0], [x1, 8.0], [x0, 8.0]], closed: true, inverted: false };
|
||||
// Masks 1..4: foreground squares; mask 5: a horizontal cleanup stroke across the middle.
|
||||
let masks = vec![
|
||||
sq(1.0, 5.0),
|
||||
sq(7.0, 11.0),
|
||||
sq(13.0, 17.0),
|
||||
sq(19.0, 23.0),
|
||||
MaskShape { name: String::new(), points: vec![[0.0, 5.0], [30.0, 5.0]], closed: false, inverted: false },
|
||||
];
|
||||
let env = EffectEnv { masks: &masks, ..Default::default() };
|
||||
let img = Image::filled(30, 10, [0.5, 0.5, 0.5, 1.0]);
|
||||
let vals = [
|
||||
("foreground", Value::Enum(1)),
|
||||
("additionalForeground/foreground1", Value::Enum(2)),
|
||||
("additionalForeground/foreground2", Value::Enum(3)),
|
||||
("additionalForeground/foreground5", Value::Enum(4)),
|
||||
];
|
||||
let out = run_fx("ec.key.innerouter", &vals, img.clone(), 0.0, env).img;
|
||||
for x in [3, 9, 15, 21] {
|
||||
assert_eq!(out.get(x, 3)[3], 1.0, "foreground square at {x}");
|
||||
}
|
||||
assert_eq!(out.get(27, 3)[3], 0.0);
|
||||
// Cleanup Background along mask 5 erases a band; Cleanup Foreground restores outside.
|
||||
let mut bg = vals.to_vec();
|
||||
bg.extend([
|
||||
("cleanupBackground/cleanupBackground1/path", Value::Enum(5)),
|
||||
("cleanupBackground/cleanupBackground1/brushRadius", num(1.5)),
|
||||
("cleanupBackground/cleanupBackground1/brushPressure", num(100.0)),
|
||||
]);
|
||||
let erased = run_fx("ec.key.innerouter", &bg, img.clone(), 0.0, env).img;
|
||||
assert!(erased.get(3, 5)[3] < 0.1 && erased.get(3, 2)[3] > 0.9);
|
||||
let mut fg = vals.to_vec();
|
||||
fg.extend([
|
||||
("cleanupForeground/cleanupForeground3/path", Value::Enum(5)),
|
||||
("cleanupForeground/cleanupForeground3/brushRadius", num(1.5)),
|
||||
("cleanupForeground/cleanupForeground3/brushPressure", num(100.0)),
|
||||
]);
|
||||
let painted = run_fx("ec.key.innerouter", &fg, img, 0.0, env).img;
|
||||
assert!(painted.get(27, 5)[3] > 0.9 && painted.get(27, 2)[3] < 0.1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,109 @@ fn correct(c: [f32; 3], sat: f32, contrast: f32, bright: f32) -> [f32; 3] {
|
|||
c.map(|v| ((v - 0.5) * contrast + 0.5) * bright)
|
||||
}
|
||||
|
||||
/// Colour Suppression choices.
|
||||
const SUPPRESS: [&str; 7] = ["None", "Red", "Green", "Blue", "Cyan", "Magenta", "Yellow"];
|
||||
/// Source Crops' X / Y Method choices.
|
||||
const CROP_METHODS: [&str; 4] = ["Colour", "Repeat", "Reflect", "Wrap"];
|
||||
|
||||
/// (colour, balance 0..1, amount 0..1) of a Colour Suppression setting.
|
||||
fn suppression(ctx: &EffectCtx, kind: &str, bal: &str, amt: &str) -> (u32, f32, f32) {
|
||||
(ctx.params.e(kind), (ctx.params.get(bal).map(Value::as_f64).unwrap_or(50.0) / 100.0) as f32, (ctx.params.f(amt) / 100.0) as f32)
|
||||
}
|
||||
|
||||
/// Colour Suppression: remove the excess of a primary (over a Balance-weighted mix of the other
|
||||
/// two) or of a secondary (the smaller of its two primaries over the third).
|
||||
fn suppress(mut c: [f32; 3], (kind, bal, amt): (u32, f32, f32)) -> [f32; 3] {
|
||||
if kind == 0 || amt <= 0.0 {
|
||||
return c;
|
||||
}
|
||||
match kind {
|
||||
1..=3 => {
|
||||
let k = kind as usize - 1;
|
||||
let (a, b) = ((k + 1) % 3, (k + 2) % 3);
|
||||
let limit = c[a] * bal + c[b] * (1.0 - bal);
|
||||
c[k] -= (c[k] - limit).max(0.0) * amt;
|
||||
}
|
||||
_ => {
|
||||
// Cyan = G + B over R, Magenta = R + B over G, Yellow = R + G over B.
|
||||
let third = match kind {
|
||||
4 => 0,
|
||||
5 => 1,
|
||||
_ => 2,
|
||||
};
|
||||
let (a, b) = ((third + 1) % 3, (third + 2) % 3);
|
||||
let excess = (c[a].min(c[b]) - c[third]).max(0.0) * amt;
|
||||
c[a] -= excess;
|
||||
c[b] -= excess;
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// (tint offset) of a Colour Balancing wheel: hue (degrees) and saturation (0..1).
|
||||
fn balance(ctx: &EffectCtx, hue: &str, sat: &str) -> [f32; 3] {
|
||||
let s = (ctx.params.f(sat) / 100.0) as f32;
|
||||
if s <= 0.0 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
let (r, g, b) = effectcraft_color::hsl_to_rgb((ctx.params.f(hue) / 360.0).rem_euclid(1.0) as f32, 1.0, 0.5);
|
||||
let l = luminance(r, g, b);
|
||||
[(r - l) * s * 0.5, (g - l) * s * 0.5, (b - l) * s * 0.5]
|
||||
}
|
||||
|
||||
/// Colour Balancing: shift the colour towards the wheel's hue, keeping its luminance.
|
||||
fn colour_balance(c: [f32; 3], tint: [f32; 3]) -> [f32; 3] {
|
||||
if tint == [0.0; 3] {
|
||||
return c;
|
||||
}
|
||||
[c[0] + tint[0], c[1] + tint[1], c[2] + tint[2]].map(|v| v.max(0.0))
|
||||
}
|
||||
|
||||
/// The layer with Source Crops applied: inside the crop the picture, outside it the Edge Colour
|
||||
/// (X / Y Method Colour) or the kept picture repeated, reflected or wrapped.
|
||||
fn crop_source(ctx: &EffectCtx, b: &Buf) -> Image {
|
||||
let pr = ctx.params;
|
||||
let crops = [pr.f("sourceCrops/cropLeft"), pr.f("sourceCrops/cropRight"), pr.f("sourceCrops/cropTop"), pr.f("sourceCrops/cropBottom")].map(|v| v / 100.0);
|
||||
if crops.iter().all(|c| *c <= 0.0) {
|
||||
return b.img.clone();
|
||||
}
|
||||
let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale);
|
||||
let (x0, x1) = (b.offset[0] + crops[0] * lw, b.offset[0] + (1.0 - crops[1]) * lw);
|
||||
let (y0, y1) = (b.offset[1] + crops[2] * lh, b.offset[1] + (1.0 - crops[3]) * lh);
|
||||
let methods = [pr.e("sourceCrops/xMethod"), pr.e("sourceCrops/yMethod")];
|
||||
let ec = pr.color("sourceCrops/edgeColour");
|
||||
let ea = (pr.f("sourceCrops/edgeColourAlpha") / 100.0) as f32;
|
||||
if methods == [0, 0] && ea <= 0.0 {
|
||||
// Transparent edge colour: the borders are cut from the result instead (keying sees
|
||||
// the whole picture).
|
||||
return b.img.clone();
|
||||
}
|
||||
let edge = premul([ec[0], ec[1], ec[2]], ea);
|
||||
// Fold a coordinate into [lo, hi) by a method; None = outside with Colour.
|
||||
let fold = |v: f64, lo: f64, hi: f64, m: u32| -> Option<f64> {
|
||||
let span = (hi - lo).max(1.0);
|
||||
if v >= lo && v < hi {
|
||||
return Some(v);
|
||||
}
|
||||
match m {
|
||||
1 => Some(v.clamp(lo, hi - 0.5)),
|
||||
2 => {
|
||||
let t = (v - lo).rem_euclid(2.0 * span);
|
||||
Some(lo + if t >= span { 2.0 * span - t - 0.5 } else { t })
|
||||
}
|
||||
3 => Some(lo + (v - lo).rem_euclid(span)),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
crate::util::gen_image(b.img.width, b.img.height, |x, y| {
|
||||
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
match (fold(fx, x0, x1, methods[0]), fold(fy, y0, y1, methods[1])) {
|
||||
(Some(sx), Some(sy)) => b.img.get_clamped(sx.floor() as i64, sy.floor() as i64),
|
||||
_ => edge,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let view = pr.e("view");
|
||||
|
|
@ -151,7 +254,9 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let despot_b = pr.f("screenMatte/screenDespotBlack") * b.scale;
|
||||
let despot_w = pr.f("screenMatte/screenDespotWhite") * b.scale;
|
||||
|
||||
let src = b.img.clone();
|
||||
// Source Crops: the cropped borders take the Edge Colour, or repeat / reflect / wrap the
|
||||
// kept picture (X Method / Y Method), before keying.
|
||||
let src = crop_source(ctx, &b);
|
||||
let blurred;
|
||||
let key_src: &Image = if preblur > 0.0 {
|
||||
blurred = gaussian_blur(&src, preblur * 0.5, preblur * 0.5, true);
|
||||
|
|
@ -247,6 +352,22 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
pr.f("edgeColourCorrection/edgeBrightness") as f32 / 100.0 + 1.0,
|
||||
);
|
||||
let unpremultiply = pr.b("unpremultiplyResult");
|
||||
let (x_method, y_method) = (pr.e("sourceCrops/xMethod"), pr.e("sourceCrops/yMethod"));
|
||||
let edge_alpha = pr.f("sourceCrops/edgeColourAlpha") as f32 / 100.0;
|
||||
let fg_sup = suppression(
|
||||
ctx,
|
||||
"foregroundColourCorrection/colourSuppression",
|
||||
"foregroundColourCorrection/suppressionBalance",
|
||||
"foregroundColourCorrection/suppressionAmount",
|
||||
);
|
||||
let fg_bal = balance(ctx, "foregroundColourCorrection/colourBalanceHue", "foregroundColourCorrection/colourBalanceSaturation");
|
||||
let edge_sup = suppression(
|
||||
ctx,
|
||||
"edgeColourCorrection/edgeColourSuppression",
|
||||
"edgeColourCorrection/edgeSuppressionBalance",
|
||||
"edgeColourCorrection/edgeSuppressionAmount",
|
||||
);
|
||||
let edge_bal = balance(ctx, "edgeColourCorrection/edgeColourBalanceHue", "edgeColourCorrection/edgeColourBalanceSaturation");
|
||||
let crops = [pr.f("sourceCrops/cropLeft"), pr.f("sourceCrops/cropRight"), pr.f("sourceCrops/cropTop"), pr.f("sourceCrops/cropBottom")].map(|v| v / 100.0);
|
||||
let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]);
|
||||
let w = b.img.width as usize;
|
||||
|
|
@ -258,7 +379,7 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
// Source crops (layer fractions from each edge).
|
||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
let (lx, ly) = ((x - off[0]) / scale / lw.max(1e-9), (y - off[1]) / scale / lh.max(1e-9));
|
||||
let cropped = lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3];
|
||||
let cropped = (lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3]) && edge_alpha <= 0.0 && x_method == 0 && y_method == 0;
|
||||
// Screen removal on the raw (unclipped) transparency, then despill.
|
||||
let ar = raw.data[i].max(1e-4);
|
||||
let mut fg = [0, 1, 2].map(|k| ((c[k] - (1.0 - ar) * screen[k]) / ar).max(0.0));
|
||||
|
|
@ -287,11 +408,11 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
fg = apply_replace(fg, replace, rc, raised);
|
||||
fg = apply_replace(fg, in_replace, in_rc, raised_in);
|
||||
if fg_cc {
|
||||
fg = correct(fg, fs, fc, fbr);
|
||||
fg = colour_balance(suppress(correct(fg, fs, fc, fbr), fg_sup), fg_bal);
|
||||
}
|
||||
let edge = edges.as_ref().map_or(0.0, |e| e[i]);
|
||||
if edge > 0.0 {
|
||||
let ce = correct(fg, es, ec, ebr);
|
||||
let ce = colour_balance(suppress(correct(fg, es, ec, ebr), edge_sup), edge_bal);
|
||||
fg = [0, 1, 2].map(|k| fg[k] + (ce[k] - fg[k]) * edge);
|
||||
}
|
||||
let grey = |v: f32| [v, v, v, 1.0];
|
||||
|
|
@ -380,6 +501,11 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("foregroundColourCorrection/saturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
cc("foregroundColourCorrection/contrast", "Contrast"),
|
||||
cc("foregroundColourCorrection/brightness", "Brightness"),
|
||||
p("foregroundColourCorrection/colourSuppression", "Colour Suppression", Value::Enum(0), popup(&SUPPRESS)),
|
||||
pp("foregroundColourCorrection/suppressionBalance", "Suppression Balance", pct(50.0)),
|
||||
pp("foregroundColourCorrection/suppressionAmount", "Suppression Amount", pct(0.0)),
|
||||
p("foregroundColourCorrection/colourBalanceHue", "Colour Balance Hue", num(0.0), ParamUi::Angle),
|
||||
pp("foregroundColourCorrection/colourBalanceSaturation", "Colour Balance Saturation", pct(0.0)),
|
||||
// Edge Colour Correction
|
||||
p("edgeColourCorrection/enableEdgeColourCorrection", "Enable Edge Colour Correction", Value::Bool(false), ParamUi::Checkbox),
|
||||
pp("edgeColourCorrection/edgeHardness", "Edge Hardness", pct(0.0)),
|
||||
|
|
@ -388,7 +514,16 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("edgeColourCorrection/edgeSaturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
cc("edgeColourCorrection/edgeContrast", "Contrast"),
|
||||
cc("edgeColourCorrection/edgeBrightness", "Brightness"),
|
||||
p("edgeColourCorrection/edgeColourSuppression", "Colour Suppression", Value::Enum(0), popup(&SUPPRESS)),
|
||||
pp("edgeColourCorrection/edgeSuppressionBalance", "Suppression Balance", pct(50.0)),
|
||||
pp("edgeColourCorrection/edgeSuppressionAmount", "Suppression Amount", pct(0.0)),
|
||||
p("edgeColourCorrection/edgeColourBalanceHue", "Colour Balance Hue", num(0.0), ParamUi::Angle),
|
||||
pp("edgeColourCorrection/edgeColourBalanceSaturation", "Colour Balance Saturation", pct(0.0)),
|
||||
// Source Crops
|
||||
p("sourceCrops/xMethod", "X Method", Value::Enum(0), popup(&CROP_METHODS)),
|
||||
p("sourceCrops/yMethod", "Y Method", Value::Enum(0), popup(&CROP_METHODS)),
|
||||
p("sourceCrops/edgeColour", "Edge Colour", col(0.0, 0.0, 0.0), ParamUi::Color),
|
||||
pp("sourceCrops/edgeColourAlpha", "Edge Colour Alpha", pct(0.0)),
|
||||
pp("sourceCrops/cropLeft", "Left", pct(0.0)),
|
||||
pp("sourceCrops/cropRight", "Right", pct(0.0)),
|
||||
pp("sourceCrops/cropTop", "Top", pct(0.0)),
|
||||
|
|
@ -500,4 +635,44 @@ mod tests {
|
|||
assert_eq!(g.sub("insideMask").and_then(|s| s.get("insideMask")).map(|p| p.name.as_str()), Some("Inside Mask"));
|
||||
assert!(g.get("clipWhite").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_suppression_balancing_and_crop_methods() {
|
||||
// Suppress Red pulls red down to the balance of green and blue; Cyan the shared G+B.
|
||||
assert!((suppress([0.9, 0.3, 0.1], (1, 0.5, 1.0))[0] - 0.2).abs() < 1e-6);
|
||||
let c = suppress([0.1, 0.8, 0.6], (4, 0.5, 1.0));
|
||||
assert!((c[1] - 0.3).abs() < 1e-6 && (c[2] - 0.1).abs() < 1e-6);
|
||||
assert_eq!(suppress([0.9, 0.3, 0.1], (1, 0.5, 0.0)), [0.9, 0.3, 0.1]);
|
||||
// Foreground colour correction with suppression / balancing changes the keyed colour.
|
||||
let base = [("screenColour", green()), ("foregroundColourCorrection/enableColourCorrection", Value::Bool(true))];
|
||||
let run = |extra: &[(&str, Value)]| {
|
||||
let mut v: Vec<(&str, Value)> = base.to_vec();
|
||||
v.extend_from_slice(extra);
|
||||
run_fx("ec.keying.keylight", &v, plate(), 0.0, EffectEnv::default()).img.get(15, 15)
|
||||
};
|
||||
let plain = run(&[]);
|
||||
let sup = run(&[("foregroundColourCorrection/colourSuppression", Value::Enum(1)), ("foregroundColourCorrection/suppressionAmount", num(100.0))]);
|
||||
assert!(sup[0] < plain[0] - 0.1, "{sup:?} vs {plain:?}");
|
||||
let bal = run(&[("foregroundColourCorrection/colourBalanceHue", num(240.0)), ("foregroundColourCorrection/colourBalanceSaturation", num(100.0))]);
|
||||
assert!(bal[2] > plain[2] + 0.05);
|
||||
// Source Crops: Colour fills the cropped border; Repeat copies the kept edge.
|
||||
let crop = [("screenColour", green()), ("sourceCrops/cropLeft", num(10.0)), ("view", Value::Enum(1))];
|
||||
let filled =
|
||||
run_fx("ec.keying.keylight", &[crop.as_slice(), &[("sourceCrops/edgeColourAlpha", num(100.0))]].concat(), plate(), 0.0, EffectEnv::default()).img;
|
||||
assert_eq!(filled.get(1, 15)[0], 1.0, "edge colour alpha shows in the source alpha view");
|
||||
let src = crop_source(
|
||||
&crate::EffectCtx {
|
||||
params: &crate::Params {
|
||||
values: [("sourceCrops/cropLeft".to_string(), num(25.0)), ("sourceCrops/xMethod".to_string(), Value::Enum(1))].into_iter().collect(),
|
||||
},
|
||||
time: 0.0,
|
||||
layer_size: [40.0, 30.0],
|
||||
seed: 0,
|
||||
adjustment: false,
|
||||
env: Default::default(),
|
||||
},
|
||||
&Buf { img: plate(), offset: [0.0; 2], scale: 1.0 },
|
||||
);
|
||||
assert_eq!(src.get(2, 15), plate().get(10, 15), "repeat");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ pub mod audio_fx;
|
|||
mod blur2;
|
||||
mod blur3;
|
||||
pub mod camera_tracker;
|
||||
mod card3d;
|
||||
pub mod catalog;
|
||||
mod channel;
|
||||
mod channel2;
|
||||
|
|
@ -64,18 +65,22 @@ pub mod warp_stab;
|
|||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use color_fx::{exposure_settings, levels_clip};
|
||||
pub use card3d::{CompLight, CompScene};
|
||||
pub use color_fx::{
|
||||
HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip,
|
||||
};
|
||||
pub use color2::Curve;
|
||||
pub use distort::transform_shutter;
|
||||
pub use distort2::parse_mesh;
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
|
||||
pub use effectcraft_raster::{AuxChannels, Image};
|
||||
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS};
|
||||
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_operation};
|
||||
pub use sim::particle_state;
|
||||
pub use sim3::playground_state;
|
||||
// CPU helpers the GPU kernels share (effectcraft-gpu).
|
||||
pub use blur2::camera_lens_spans;
|
||||
pub use blur2::{camera_lens_plain, camera_lens_spans};
|
||||
pub use generate::gen_mode;
|
||||
pub use generate2::pattern_kind as cell_pattern_kind;
|
||||
pub use noise::GrainLook;
|
||||
|
|
@ -273,6 +278,16 @@ pub trait EffectHost: Sync {
|
|||
fn aux(&self) -> Option<std::sync::Arc<AuxChannels>> {
|
||||
None
|
||||
}
|
||||
/// The composition's camera and first light relative to the effect's layer (Card
|
||||
/// Dance / Shatter / Card Wipe's Comp Camera and First Comp Light). `None` when unknown.
|
||||
fn comp_scene(&self) -> Option<CompScene> {
|
||||
None
|
||||
}
|
||||
/// The running effect's own parameters evaluated at another **layer time** (Radio Waves'
|
||||
/// Parameters Are Set At: Birth). `None` when unavailable.
|
||||
fn params_at(&self, _layer_time: f64) -> Option<Params> {
|
||||
None
|
||||
}
|
||||
/// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the
|
||||
/// CPU.
|
||||
fn particles(&self) -> Option<&dyn psim::ParticleSim> {
|
||||
|
|
@ -296,6 +311,10 @@ pub struct EffectEnv<'a> {
|
|||
pub working_space: Option<effectcraft_color::ColorSpace>,
|
||||
/// Working-space pixels are linear light (Linearize Working Space).
|
||||
pub working_linear: bool,
|
||||
/// The composition's shutter (angle and phase in degrees, samples per frame) when both the
|
||||
/// comp's and the layer's motion blur switches are on (Transform's Use Composition's
|
||||
/// Shutter Angle).
|
||||
pub shutter: Option<(f64, f64, u32)>,
|
||||
}
|
||||
|
||||
/// What an effect gets to render with.
|
||||
|
|
@ -537,6 +556,19 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
|||
("toleranceGroup", "Tolerance"),
|
||||
("inputPhase", "Input Phase"),
|
||||
("outputCycle", "Output Cycle"),
|
||||
("modify", "Modify"),
|
||||
("pixelSelection", "Pixel Selection"),
|
||||
("masking", "Masking"),
|
||||
("details", "Details"),
|
||||
("reds", "Reds"),
|
||||
("yellows", "Yellows"),
|
||||
("greens", "Greens"),
|
||||
("cyans", "Cyans"),
|
||||
("blues", "Blues"),
|
||||
("magentas", "Magentas"),
|
||||
("whites", "Whites"),
|
||||
("neutrals", "Neutrals"),
|
||||
("blacks", "Blacks"),
|
||||
("moreOptions", "More Options"),
|
||||
("basicCorrection", "Basic Correction"),
|
||||
("whiteBalance", "White Balance"),
|
||||
|
|
@ -546,9 +578,16 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
|||
("curves", "Curves"),
|
||||
("colorWheels", "Color Wheels"),
|
||||
("vignette", "Vignette"),
|
||||
("rgbCurves", "RGB Curves"),
|
||||
("hueSaturationCurves", "Hue Saturation Curves"),
|
||||
("hslSecondary", "HSL Secondary"),
|
||||
("key", "Key"),
|
||||
("refine", "Refine"),
|
||||
("correction", "Correction"),
|
||||
// Camera Lens Blur.
|
||||
("irisProperties", "Iris Properties"),
|
||||
("highlight", "Highlight"),
|
||||
("blurMap", "Blur Map"),
|
||||
// Numbers.
|
||||
("format", "Format"),
|
||||
("fillAndStroke", "Fill and Stroke"),
|
||||
|
|
@ -569,9 +608,15 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
|||
("fineTuning", "Fine Tuning"),
|
||||
("unsharpMask", "Unsharp Mask"),
|
||||
("noiseOptions", "Noise Options (Animation)"),
|
||||
("previewRegion", "Preview Region"),
|
||||
("sampling", "Sampling"),
|
||||
("temporalFiltering", "Temporal Filtering"),
|
||||
// Time: Timewarp.
|
||||
("tuning", "Tuning"),
|
||||
("motionBlur", "Motion Blur"),
|
||||
("smoothing", "Smoothing"),
|
||||
("weighting", "Weighting"),
|
||||
("sourceCrops", "Source Crops"),
|
||||
// Simulation.
|
||||
("extras", "Extras"),
|
||||
("wiggle", "Wiggle"),
|
||||
|
|
@ -586,6 +631,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
|||
("hairColor", "Hair Color"),
|
||||
("bottom", "Bottom"),
|
||||
("water", "Water"),
|
||||
("sky", "Sky"),
|
||||
("lighting", "Lighting"),
|
||||
("material", "Material"),
|
||||
("shape", "Shape"),
|
||||
|
|
@ -593,7 +639,12 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
|||
("force2", "Force 2"),
|
||||
("bubbles", "Bubbles"),
|
||||
("rendering", "Rendering"),
|
||||
("flowMap", "Flow Map"),
|
||||
("heightMapControls", "Height Map Controls"),
|
||||
("wireframeControls", "Wireframe Controls"),
|
||||
("ground", "Ground"),
|
||||
("geometric", "Geometric Distortion"),
|
||||
("target", "Target Point"),
|
||||
("simulation", "Simulation"),
|
||||
("producer1", "Producer 1"),
|
||||
("producer2", "Producer 2"),
|
||||
|
|
@ -606,6 +657,11 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
|||
("xScale", "X Scale"),
|
||||
("yScale", "Y Scale"),
|
||||
("cameraPosition", "Camera Position"),
|
||||
("cornerPins", "Corner Pins"),
|
||||
("positionJitter", "Position Jitter"),
|
||||
("rotationJitter", "Rotation Jitter"),
|
||||
("textures", "Textures"),
|
||||
("gradient", "Gradient"),
|
||||
("cannon", "Cannon"),
|
||||
("grid", "Grid"),
|
||||
("layerExploder", "Layer Exploder"),
|
||||
|
|
@ -614,8 +670,36 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
|||
("repel", "Repel"),
|
||||
("wall", "Wall"),
|
||||
("persistentPropertyMapper", "Persistent Property Mapper"),
|
||||
("ephemeralPropertyMapper", "Ephemeral Property Mapper"),
|
||||
("particleExploder", "Particle Exploder"),
|
||||
("affects", "Affects"),
|
||||
("options", "Options"),
|
||||
// Keying, Matte, Obsolete (Key Light's groups are in `keylight::GROUPS`).
|
||||
("ultraSettings", "Ultra Settings"),
|
||||
("additionalForeground", "Additional Foreground"),
|
||||
("additionalBackground", "Additional Background"),
|
||||
("cleanupForeground", "Cleanup Foreground"),
|
||||
("cleanupBackground", "Cleanup Background"),
|
||||
("cleanupForeground1", "Cleanup Foreground 1"),
|
||||
("cleanupForeground2", "Cleanup Foreground 2"),
|
||||
("cleanupForeground3", "Cleanup Foreground 3"),
|
||||
("cleanupForeground4", "Cleanup Foreground 4"),
|
||||
("cleanupForeground5", "Cleanup Foreground 5"),
|
||||
("cleanupForeground6", "Cleanup Foreground 6"),
|
||||
("cleanupForeground7", "Cleanup Foreground 7"),
|
||||
("cleanupForeground8", "Cleanup Foreground 8"),
|
||||
("cleanupForeground9", "Cleanup Foreground 9"),
|
||||
("cleanupForeground10", "Cleanup Foreground 10"),
|
||||
("cleanupBackground1", "Cleanup Background 1"),
|
||||
("cleanupBackground2", "Cleanup Background 2"),
|
||||
("cleanupBackground3", "Cleanup Background 3"),
|
||||
("cleanupBackground4", "Cleanup Background 4"),
|
||||
("cleanupBackground5", "Cleanup Background 5"),
|
||||
("cleanupBackground6", "Cleanup Background 6"),
|
||||
("cleanupBackground7", "Cleanup Background 7"),
|
||||
("cleanupBackground8", "Cleanup Background 8"),
|
||||
("cleanupBackground9", "Cleanup Background 9"),
|
||||
("cleanupBackground10", "Cleanup Background 10"),
|
||||
("fillAndStroke", "Fill and Stroke"),
|
||||
("pathOptions", "Path Options"),
|
||||
("controlPoints", "Control Points"),
|
||||
|
|
@ -623,6 +707,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
|||
("orientation", "Orientation"),
|
||||
("paragraph", "Paragraph"),
|
||||
("preview", "Preview"),
|
||||
("jitterSettings", "Jitter Settings"),
|
||||
// Generate.
|
||||
("positionsColors", "Positions & Colors"),
|
||||
("feather", "Feather"),
|
||||
|
|
@ -630,6 +715,8 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
|||
("polygon", "Polygon"),
|
||||
("waveMotion", "Wave Motion"),
|
||||
("waveStroke", "Stroke"),
|
||||
("imageContour", "Image Contour"),
|
||||
("waveMask", "Mask"),
|
||||
("coreSettings", "Core Settings"),
|
||||
("glowSettings", "Glow Settings"),
|
||||
("expertSettings", "Expert Settings"),
|
||||
|
|
@ -732,11 +819,15 @@ pub const TIME_DEPENDENT: &[&str] = &[
|
|||
"ec.stylize.strobe",
|
||||
"ec.noise.noise",
|
||||
"ec.noise.addgrain",
|
||||
// Remove Grain's temporal filtering reads neighbouring frames.
|
||||
"ec.noise.removegrain",
|
||||
"ec.noise.noisealpha",
|
||||
"ec.noise.noisehlsauto",
|
||||
"ec.obsolete.lightning",
|
||||
"ec.text.timecode",
|
||||
"ec.text.numbers",
|
||||
// Path Text's jitter changes every frame.
|
||||
"ec.obsolete.pathtext",
|
||||
// Time effects read neighbouring frames of the layer.
|
||||
"ec.time.echo",
|
||||
"ec.time.posterizetime",
|
||||
|
|
@ -746,6 +837,14 @@ pub const TIME_DEPENDENT: &[&str] = &[
|
|||
"ec.time.ccforcemotionblur",
|
||||
"ec.time.ccwidetime",
|
||||
"ec.time.pixelmotionblur",
|
||||
// Refine mattes' Reduce Chatter / motion blur read neighbouring frames.
|
||||
"ec.matte.refinesoft",
|
||||
"ec.matte.refinehard",
|
||||
// Card Wipe's position / rotation jitter moves with time.
|
||||
"ec.transition.cardwipe",
|
||||
// Transform's motion blur and Radio Waves' birth parameters read the parameters at
|
||||
// other times.
|
||||
"ec.distort.transform",
|
||||
// Temporal Smoothing reads neighbouring frames.
|
||||
"ec.color.autolevels",
|
||||
"ec.color.autocontrast",
|
||||
|
|
@ -780,7 +879,8 @@ pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf {
|
|||
#[cfg(test)]
|
||||
pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf {
|
||||
let s = find(id).unwrap_or_else(|| panic!("no effect {id}"));
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
let size = [img.width as f64, img.height as f64];
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), default_value(p, size))).collect() };
|
||||
for (k, v) in vals {
|
||||
assert!(params.values.contains_key(*k), "{id}: unknown param {k}");
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
|
|
|
|||
|
|
@ -93,38 +93,91 @@ fn refine(ctx: &EffectCtx, mut b: Buf, hard: bool) -> Buf {
|
|||
let hi = morph_plane(&a, r, r, true);
|
||||
let lo = morph_plane(&a, r, r, false);
|
||||
let band = |i: usize| hi.data[i] - lo.data[i] > 1e-3;
|
||||
let mut na = Plane::new(a.w, a.h);
|
||||
if edge_details {
|
||||
let guide = Plane::from_image(&b.img, |px| effectcraft_color::luminance(px[0], px[1], px[2]));
|
||||
let eps = 1e-4 + smooth * smooth * 0.05;
|
||||
let filtered = guided_filter(&guide, &a, r, eps);
|
||||
na.data.par_iter_mut().enumerate().for_each(|(i, v)| {
|
||||
*v = if band(i) { filtered.data[i].clamp(0.0, 1.0) } else { a.data[i] };
|
||||
});
|
||||
} else {
|
||||
na = a.clone();
|
||||
if smooth > 0.0 {
|
||||
let s = smooth as f64 * r as f64 * 0.5;
|
||||
na = gauss_plane(&na, s, s);
|
||||
}
|
||||
}
|
||||
if feather > 0.0 {
|
||||
na = gauss_plane(&na, feather, feather);
|
||||
}
|
||||
na = na.map(|v| {
|
||||
let mut v = if choke > 0.0 {
|
||||
(v - choke) / (1.0 - choke)
|
||||
} else if choke < 0.0 {
|
||||
v / (1.0 + choke)
|
||||
// The refined matte of a frame.
|
||||
let refine_alpha = |img: &effectcraft_raster::Image| -> Plane {
|
||||
let a = Plane::alpha(img);
|
||||
let hi = morph_plane(&a, r, r, true);
|
||||
let lo = morph_plane(&a, r, r, false);
|
||||
let mut na = Plane::new(a.w, a.h);
|
||||
if edge_details {
|
||||
let guide = Plane::from_image(img, |px| effectcraft_color::luminance(px[0], px[1], px[2]));
|
||||
let eps = 1e-4 + smooth * smooth * 0.05;
|
||||
let filtered = guided_filter(&guide, &a, r, eps);
|
||||
na.data.par_iter_mut().enumerate().for_each(|(i, v)| {
|
||||
*v = if hi.data[i] - lo.data[i] > 1e-3 { filtered.data[i].clamp(0.0, 1.0) } else { a.data[i] };
|
||||
});
|
||||
} else {
|
||||
v
|
||||
};
|
||||
if (contrast - 1.0).abs() > 1e-6 {
|
||||
v = (v - 0.5) * contrast + 0.5;
|
||||
na = a.clone();
|
||||
if smooth > 0.0 {
|
||||
let s = smooth as f64 * r as f64 * 0.5;
|
||||
na = gauss_plane(&na, s, s);
|
||||
}
|
||||
}
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
if invert { 1.0 - v } else { v }
|
||||
});
|
||||
if feather > 0.0 {
|
||||
na = gauss_plane(&na, feather, feather);
|
||||
}
|
||||
na.map(|v| {
|
||||
let mut v = if choke > 0.0 {
|
||||
(v - choke) / (1.0 - choke)
|
||||
} else if choke < 0.0 {
|
||||
v / (1.0 + choke)
|
||||
} else {
|
||||
v
|
||||
};
|
||||
if (contrast - 1.0).abs() > 1e-6 {
|
||||
v = (v - 0.5) * contrast + 0.5;
|
||||
}
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
if invert { 1.0 - v } else { v }
|
||||
})
|
||||
};
|
||||
let mut na = refine_alpha(&b.img);
|
||||
// Neighbouring frames of this effect's input (same pixel grid), refined the same way.
|
||||
let fps = ctx.fps();
|
||||
let frame_at = |t: f64| -> Option<Plane> {
|
||||
let o = ctx.env.host?.self_at(t, ctx.env.effect_index)?;
|
||||
(o.img.width == b.img.width && o.img.height == b.img.height && (o.offset[0] - b.offset[0]).abs() < 1e-6 && (o.offset[1] - b.offset[1]).abs() < 1e-6)
|
||||
.then(|| refine_alpha(&o.img))
|
||||
};
|
||||
// Reduce Chatter: where the previous and next frames agree but this one differs, the matte
|
||||
// is chattering; average the three there. Where they disagree (motion) it is left alone.
|
||||
let chatter = (pr.f("reduceChatter") / 100.0).clamp(0.0, 1.0) as f32;
|
||||
if chatter > 0.0 {
|
||||
let prev = frame_at(ctx.time - 1.0 / fps);
|
||||
let next = frame_at(ctx.time + 1.0 / fps);
|
||||
if let (Some(p), Some(n)) = (&prev, &next) {
|
||||
na.data.par_iter_mut().enumerate().for_each(|(i, v)| {
|
||||
let (c, a, z) = (*v, p.data[i], n.data[i]);
|
||||
let steady = (1.0 - (a - z).abs() / 0.5).clamp(0.0, 1.0);
|
||||
*v = c + ((a + c + z) / 3.0 - c) * chatter * steady;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Motion blur: the matte averaged over the shutter (centred on the frame).
|
||||
if pr.b("useMotionBlur") {
|
||||
let g = |id: &str| pr.get(&format!("motionBlur/{id}")).map(Value::as_f64);
|
||||
let samples = g("motionBlurSamples").unwrap_or(16.0).clamp(1.0, 64.0) as usize;
|
||||
let samples = if pr.b("motionBlur/higherQuality") { samples * 2 } else { samples }.min(64);
|
||||
let span = g("shutterAngle").unwrap_or(180.0).clamp(0.0, 720.0) / 360.0 / fps;
|
||||
if samples > 1 && span > 0.0 {
|
||||
let mut acc = na.clone();
|
||||
let mut n = 1.0f32;
|
||||
for k in 0..samples {
|
||||
let t = ctx.time - span * 0.5 + span * k as f64 / (samples - 1) as f64;
|
||||
if (t - ctx.time).abs() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = frame_at(t) {
|
||||
acc.data.iter_mut().zip(&p.data).for_each(|(a, q)| *a += q);
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
if n > 1.0 {
|
||||
acc.data.iter_mut().for_each(|a| *a /= n);
|
||||
na = acc;
|
||||
}
|
||||
}
|
||||
}
|
||||
if view_edges {
|
||||
// The refined matte in grey with the analysed edge band tinted.
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
|
|
@ -190,6 +243,16 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
check(pre[3], "View Decontamination Map", false),
|
||||
]
|
||||
};
|
||||
// Reduce Chatter and the motion-blur settings.
|
||||
let temporal = || {
|
||||
vec![
|
||||
p("reduceChatter", "Reduce Chatter", num(0.0), pct0()),
|
||||
check("useMotionBlur", "Use Motion Blur", false),
|
||||
p("motionBlur/motionBlurSamples", "Motion Blur Samples", num(16.0), slider(1.0, 64.0, 1.0, 64.0, 0)),
|
||||
p("motionBlur/shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)),
|
||||
check("motionBlur/higherQuality", "Higher Quality", false),
|
||||
]
|
||||
};
|
||||
let radius = |d: f64| p("radius", "Additional Edge Radius", num(d), slider(1.0, 200.0, 1.0, 50.0, 1));
|
||||
let mut soft_params = vec![
|
||||
check("calculateEdgeDetails", "Calculate Edge Details", true),
|
||||
|
|
@ -199,8 +262,9 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("feather", "Feather", num(10.0), pct0()),
|
||||
p("contrast", "Contrast", num(0.0), pct0()),
|
||||
p("shiftEdge", "Shift Edge", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)),
|
||||
check("decontaminate", "Decontaminate Edge Colors", true),
|
||||
];
|
||||
soft_params.extend(temporal());
|
||||
soft_params.push(check("decontaminate", "Decontaminate Edge Colors", true));
|
||||
soft_params.extend(decon(&[
|
||||
"decontamination/decontaminationAmount",
|
||||
"decontamination/extendWhereSmoothed",
|
||||
|
|
@ -214,8 +278,9 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("smooth", "Smooth", num(20.0), pct0()),
|
||||
p("feather", "Feather", num(0.0), pct0()),
|
||||
p("choke", "Choke", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)),
|
||||
check("decontaminate", "Decontaminate Edge Colors", true),
|
||||
];
|
||||
hard_params.extend(temporal());
|
||||
hard_params.push(check("decontaminate", "Decontaminate Edge Colors", true));
|
||||
hard_params.extend(decon(&["decontaminationAmount", "extendWhereSmoothed", "increaseDecontaminationRadius", "viewDecontaminationMap"]));
|
||||
// Our extras: the analysed edge band's radius, alpha contrast and inversion.
|
||||
hard_params.push(radius(3.0));
|
||||
|
|
@ -345,4 +410,63 @@ mod tests {
|
|||
assert!(out.get(0, 0)[3] < 0.01);
|
||||
assert!(out.data.iter().all(|p| (0.0..=1.0).contains(&p[3])));
|
||||
}
|
||||
|
||||
/// A white square whose left edge sits at x = 10 + `jitter` × (frame parity) + `speed` × frame.
|
||||
struct Edge {
|
||||
jitter: i64,
|
||||
speed: i64,
|
||||
}
|
||||
fn edge(x0: i64) -> Image {
|
||||
let mut img = Image::new(40, 20);
|
||||
for y in 4..16 {
|
||||
for x in x0.max(0)..(x0 + 16).min(40) {
|
||||
img.set(x as u32, y, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
impl crate::EffectHost for Edge {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
|
||||
let f = (t * 10.0).round() as i64;
|
||||
Some(Buf { img: edge(10 + self.jitter * (f & 1) + self.speed * f), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
}
|
||||
|
||||
fn run_t(id: &str, over: &[(&str, Value)], t: f64, host: &Edge) -> Image {
|
||||
let f = (t * 10.0).round() as i64;
|
||||
let img = edge(10 + host.jitter * (f & 1) + host.speed * f);
|
||||
let env = crate::EffectEnv { host: Some(host), frame_rate: 10.0, ..Default::default() };
|
||||
crate::run_fx(id, over, img, t, env).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_mattes_reduce_chatter_and_motion_blur() {
|
||||
for id in ["ec.matte.refinesoft", "ec.matte.refinehard"] {
|
||||
let jitter = Edge { jitter: 1, speed: 0 };
|
||||
let base = [("feather", num(0.0)), ("smooth", num(0.0)), ("decontaminate", Value::Bool(false))];
|
||||
let with = |extra: &[(&str, Value)], t: f64, h: &Edge| {
|
||||
let mut v: Vec<(&str, Value)> = base.to_vec();
|
||||
v.extend_from_slice(extra);
|
||||
run_t(id, &v, t, h)
|
||||
};
|
||||
let diff = |a: &Image, b: &Image| a.data.iter().zip(&b.data).map(|(p, q)| (p[3] - q[3]).abs()).sum::<f32>();
|
||||
let (a0, a1) = (with(&[], 1.0, &jitter), with(&[], 1.1, &jitter));
|
||||
let (c0, c1) = (with(&[("reduceChatter", num(100.0))], 1.0, &jitter), with(&[("reduceChatter", num(100.0))], 1.1, &jitter));
|
||||
assert!(diff(&c0, &c1) < diff(&a0, &a1) * 0.7, "{id}: chatter {} vs {}", diff(&c0, &c1), diff(&a0, &a1));
|
||||
// Motion blur smears a moving matte edge along the motion.
|
||||
let moving = Edge { jitter: 0, speed: 4 };
|
||||
let mb = with(&[("useMotionBlur", Value::Bool(true)), ("motionBlur/shutterAngle", num(360.0))], 0.3, &moving);
|
||||
let sharp = with(&[], 0.3, &moving);
|
||||
let partial = |img: &Image| img.data.iter().filter(|p| p[3] > 0.05 && p[3] < 0.95).count();
|
||||
assert!(partial(&mb) > partial(&sharp) + 10, "{id}: {} vs {}", partial(&mb), partial(&sharp));
|
||||
// Without a host both are inert.
|
||||
assert_eq!(run(id, &[("reduceChatter", num(100.0)), ("useMotionBlur", Value::Bool(true))], edge(10)), run(id, &[], edge(10)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ pub const PARAM_ID_ALIASES: &[(&str, &str, &str)] = &[
|
|||
("ec.matte.rotobrush", "refineEdgeMatte/extendWhereSmoothed", "refineEdgeMatte/decontamination/extendWhereSmoothed"),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/increaseDecontaminationRadius", "refineEdgeMatte/decontamination/increaseDecontaminationRadius"),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/viewDecontaminationMap", "refineEdgeMatte/decontamination/viewDecontaminationMap"),
|
||||
// Keying: Inner/Outer Key's single additional masks became Additional Foreground /
|
||||
// Background 1 (of 10).
|
||||
("ec.key.innerouter", "additionalForeground", "additionalForeground/foreground1"),
|
||||
("ec.key.innerouter", "additionalBackground", "additionalBackground/background1"),
|
||||
// Generate: Radio Waves' growth speed was called Velocity; Velocity now moves the wave.
|
||||
("ec.generate.radiowaves", "velocity", "waveMotion/expansion"),
|
||||
];
|
||||
|
|
@ -153,6 +157,10 @@ pub fn legacy_default(effect: &str, param: &str) -> Option<Value> {
|
|||
("ec.noise.removegrain", "noiseReductionSettings/mode") => Value::Enum(1),
|
||||
// Audio: the Compressor's release was always manual.
|
||||
("ec.audio.compressor", "autoRelease") => Value::Bool(false),
|
||||
// Keying: Advanced Spill Suppressor's Ultra mode had no hue tolerance.
|
||||
("ec.key.advancedspill", "ultraSettings/tolerance") => Value::Scalar(100.0),
|
||||
// Transition: Block Dissolve's blocks had hard edges.
|
||||
("ec.transition.blockdissolve", "softEdges") => Value::Bool(false),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -242,6 +250,18 @@ fn is_numeric_ui(u: &ParamUi) -> bool {
|
|||
/// point defaults of added parameters. Returns whether anything changed.
|
||||
pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2]) -> bool {
|
||||
let before = g.clone();
|
||||
// Card Wipe's Back Layer was a None / Self popup; it is a layer parameter now, with "Self"
|
||||
// kept in the hidden Back Layer Is Self switch.
|
||||
let mut back_self = None;
|
||||
if spec.id == "ec.transition.cardwipe"
|
||||
&& let Some(pr) = g.get_mut("backLayer")
|
||||
&& matches!(pr.ui, ParamUi::Popup { .. })
|
||||
{
|
||||
back_self = Some(pr.value.as_enum() == 1);
|
||||
pr.value = Value::Layer(None);
|
||||
pr.keys.clear();
|
||||
pr.ui = ParamUi::Layer;
|
||||
}
|
||||
for ps in &spec.params {
|
||||
// Locate the stored property: at its spec path, under an old id, or at top level
|
||||
// before it moved into a twirl-down group.
|
||||
|
|
@ -296,6 +316,11 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
|
|||
}
|
||||
}
|
||||
}
|
||||
if let Some(on) = back_self
|
||||
&& let Some(pr) = g.get_mut("backSelf")
|
||||
{
|
||||
pr.value = Value::Bool(on);
|
||||
}
|
||||
// Refresh twirl-down group names.
|
||||
fn names(g: &mut PropGroup) {
|
||||
for c in &mut g.children {
|
||||
|
|
@ -368,4 +393,78 @@ mod tests {
|
|||
assert!(g.get("smoothness").is_none());
|
||||
assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_wipe_back_layer_popup_becomes_a_layer_parameter() {
|
||||
let spec = find("ec.transition.cardwipe").unwrap();
|
||||
let mut next = 1;
|
||||
for (old, self_) in [(1, true), (0, false)] {
|
||||
let mut g = instantiate(spec, &mut Ids(&mut next), "Card Wipe", [100.0, 50.0]);
|
||||
take_prop(&mut g, "backSelf");
|
||||
let pr = g.get_mut("backLayer").unwrap();
|
||||
pr.ui = ParamUi::Popup { options: vec!["None".into(), "Self".into()] };
|
||||
pr.value = Value::Enum(old);
|
||||
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
|
||||
assert_eq!(g.get("backLayer").unwrap().value, Value::Layer(None));
|
||||
assert_eq!(g.get("backLayer").unwrap().ui, ParamUi::Layer);
|
||||
assert_eq!(g.get("backSelf").unwrap().value, Value::Bool(self_));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_playground_mapper_targets_remap_by_label() {
|
||||
let spec = find("ec.sim.particleplayground").unwrap();
|
||||
let mut next = 1;
|
||||
let mut g = instantiate(spec, &mut Ids(&mut next), "Particle Playground", [100.0, 50.0]);
|
||||
let old: Vec<String> =
|
||||
["None", "Red", "Green", "Blue", "Kinetic Friction", "Scale", "X", "Y", "X Speed", "Y Speed", "X Force", "Y Force", "Opacity", "Mass"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let pr = prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap();
|
||||
pr.ui = ParamUi::Popup { options: old };
|
||||
pr.value = Value::Enum(8);
|
||||
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
|
||||
assert_eq!(prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap().value, Value::Enum(15), "X Speed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selective_color_ranges_move_into_details() {
|
||||
let spec = find("ec.color.selectivecolor").unwrap();
|
||||
let mut next = 1;
|
||||
let mut g = instantiate(spec, &mut Ids(&mut next), "Selective Color", [100.0, 50.0]);
|
||||
let mut pr = take_at(&mut g, "details/reds/redsCyan").unwrap();
|
||||
pr.value = Value::Scalar(40.0);
|
||||
g.children.push(pr.into());
|
||||
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
|
||||
assert!(g.get("redsCyan").is_none());
|
||||
assert_eq!(prop_at(&mut g, "details/reds/redsCyan").unwrap().value, Value::Scalar(40.0));
|
||||
assert_eq!(g.sub("details").unwrap().sub("reds").unwrap().name, "Reds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_outer_key_additional_masks_move_into_their_groups() {
|
||||
let spec = find("ec.key.innerouter").unwrap();
|
||||
let mut next = 1;
|
||||
let mut g = instantiate(spec, &mut Ids(&mut next), "Inner/Outer Key", [100.0, 50.0]);
|
||||
let mut pr = take_at(&mut g, "additionalForeground/foreground1").unwrap();
|
||||
pr.match_id = "additionalForeground".into();
|
||||
pr.value = Value::Enum(3);
|
||||
g.children.push(pr.into());
|
||||
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
|
||||
assert!(g.get("additionalForeground").is_none());
|
||||
assert_eq!(prop_at(&mut g, "additionalForeground/foreground1").unwrap().value, Value::Enum(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_grain_viewing_modes_remap_by_label() {
|
||||
let spec = find("ec.noise.matchgrain").unwrap();
|
||||
let mut next = 1;
|
||||
let mut g = instantiate(spec, &mut Ids(&mut next), "Match Grain", [100.0, 50.0]);
|
||||
let pr = g.get_mut("viewingMode").unwrap();
|
||||
pr.ui = ParamUi::Popup { options: vec!["Final Output".into(), "Noise Samples".into(), "Blending Matte".into()] };
|
||||
pr.value = Value::Enum(0);
|
||||
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
|
||||
assert_eq!(g.get("viewingMode").unwrap().value, Value::Enum(4), "Final Output");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,12 +120,48 @@ pub(crate) fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32)
|
|||
t.max(0.0).powf(0.5f32.ln() / m.ln())
|
||||
}
|
||||
|
||||
/// Glow Operation options (blend modes the glow is applied with).
|
||||
pub const GLOW_OPERATIONS: [&str; 17] = [
|
||||
"Normal",
|
||||
"Add",
|
||||
"Multiply",
|
||||
"Screen",
|
||||
"Overlay",
|
||||
"Soft Light",
|
||||
"Hard Light",
|
||||
"Color Dodge",
|
||||
"Color Burn",
|
||||
"Darken",
|
||||
"Lighten",
|
||||
"Difference",
|
||||
"Exclusion",
|
||||
"Hue",
|
||||
"Saturation",
|
||||
"Color",
|
||||
"Luminosity",
|
||||
];
|
||||
|
||||
/// Glow's Glow Operation as a blend mode (Add for projects saved before it existed).
|
||||
pub fn glow_operation(ctx: &EffectCtx) -> effectcraft_color::BlendMode {
|
||||
let name = GLOW_OPERATIONS.get(ctx.params.get("glowOperation").map(|v| v.as_enum()).unwrap_or(1) as usize).copied().unwrap_or("Add");
|
||||
effectcraft_color::BlendMode::ALL.iter().copied().find(|m| m.label() == name).unwrap_or(effectcraft_color::BlendMode::Add)
|
||||
}
|
||||
|
||||
/// Glow's Arbitrary Map: red, green and blue curves of the glow brightness, as Curves point
|
||||
/// lists `x,y …` separated by `|` (an empty or missing curve is the identity).
|
||||
fn glow_map(s: &str) -> Vec<Option<crate::Curve>> {
|
||||
let mut parts = s.split('|');
|
||||
(0..3).map(|_| parts.next().and_then(crate::Curve::parse)).collect()
|
||||
}
|
||||
|
||||
fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let thr = ctx.params.f("threshold") as f32 / 100.0;
|
||||
let radius = ctx.params.f("radius") * b.scale;
|
||||
let intensity = ctx.params.f("intensity") as f32;
|
||||
let alpha_based = ctx.params.e("based") == 0;
|
||||
let use_colors = ctx.params.e("colors") == 1;
|
||||
let arbitrary = (ctx.params.e("colors") == 2).then(|| glow_map(ctx.params.s("arbitraryMap")));
|
||||
let glow_op = glow_operation(ctx);
|
||||
let looping = ctx.params.e("colorLooping");
|
||||
let loops = ctx.params.f("colorLoops") as f32;
|
||||
let phase = (ctx.params.f("colorPhase") / 360.0) as f32;
|
||||
|
|
@ -147,7 +183,11 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
// Glow Based On: brightness from the colours, or from the alpha channel.
|
||||
let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) };
|
||||
let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0);
|
||||
if use_colors {
|
||||
if let Some(map) = &arbitrary {
|
||||
let t = glow_ab_t(l, looping, loops, phase, mid);
|
||||
let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::<Vec<f32>>();
|
||||
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
|
||||
} else if use_colors {
|
||||
let t = glow_ab_t(l, looping, loops, phase, mid);
|
||||
let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t];
|
||||
*px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a];
|
||||
|
|
@ -170,6 +210,8 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
}
|
||||
// None: the glow alone.
|
||||
2 => *o = [g[0], g[1], g[2], g[3]],
|
||||
// On Top with another Glow Operation: that blend mode.
|
||||
_ if glow_op != effectcraft_color::BlendMode::Add => *o = effectcraft_color::blend_pixel(glow_op, *o, g, 0.0),
|
||||
// On Top: add (screen-like add on premultiplied colour; alpha grows by the glow's alpha).
|
||||
_ => {
|
||||
for c in 0..3 {
|
||||
|
|
@ -571,7 +613,10 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("radius", "Glow Radius", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||
p("intensity", "Glow Intensity", num(1.0), slider(0.0, 255.0, 0.0, 4.0, 1)),
|
||||
p("operation", "Composite Original", Value::Enum(0), popup(&["On Top", "Behind", "None"])),
|
||||
p("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors"])),
|
||||
p("glowOperation", "Glow Operation", Value::Enum(1), popup(&GLOW_OPERATIONS)),
|
||||
p("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors", "Arbitrary Map"])),
|
||||
// Arbitrary Map: "red points | green points | blue points" (Curves format).
|
||||
p("arbitraryMap", "Arbitrary Map", Value::Str(String::new()), ParamUi::Hidden),
|
||||
p("colorLooping", "Color Looping", Value::Enum(2), popup(&["Sawtooth A>B", "Sawtooth B>A", "Triangle A>B>A", "Triangle B>A>B"])),
|
||||
p("colorLoops", "Color Loops", num(1.0), slider(1.0, 127.0, 1.0, 10.0, 1)),
|
||||
p("colorPhase", "Color Phase", num(0.0), ParamUi::Angle),
|
||||
|
|
@ -687,3 +732,52 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, run_fx};
|
||||
|
||||
/// A bright square on mid grey.
|
||||
fn scene() -> effectcraft_raster::Image {
|
||||
let mut img = effectcraft_raster::Image::filled(24, 24, [0.3, 0.3, 0.3, 1.0]);
|
||||
for y in 8..16 {
|
||||
for x in 8..16 {
|
||||
img.set(x, y, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glow_operation_and_arbitrary_map() {
|
||||
// Pixels addressed in layer space (Glow pads its buffer).
|
||||
struct Out(Buf);
|
||||
impl Out {
|
||||
fn get(&self, x: i64, y: i64) -> [f32; 4] {
|
||||
self.0.img.get(x + self.0.offset[0] as i64, y + self.0.offset[1] as i64)
|
||||
}
|
||||
}
|
||||
let run = |vals: &[(&str, Value)]| Out(run_fx("ec.stylize.glow", vals, scene(), 0.0, EffectEnv::default()));
|
||||
let add = run(&[]);
|
||||
assert_eq!(GLOW_OPERATIONS[1], "Add");
|
||||
// Multiply darkens around the square where Add brightens.
|
||||
let mul = run(&[("glowOperation", Value::Enum(2))]);
|
||||
assert!(add.get(6, 12)[0] > 0.31 && mul.get(6, 12)[0] <= 0.3 + 1e-4, "{:?} {:?}", add.get(6, 12), mul.get(6, 12));
|
||||
assert_eq!(
|
||||
glow_operation(&EffectCtx {
|
||||
params: &crate::Params::default(),
|
||||
time: 0.0,
|
||||
layer_size: [1.0; 2],
|
||||
seed: 0,
|
||||
adjustment: false,
|
||||
env: Default::default()
|
||||
}),
|
||||
effectcraft_color::BlendMode::Add
|
||||
);
|
||||
// Arbitrary Map: the glow takes the mapped colour (here pure red for every brightness).
|
||||
let red = run(&[("colors", Value::Enum(2)), ("arbitraryMap", Value::Str("0,1 1,1|0,0 1,0|0,0 1,0".into())), ("operation", Value::Enum(2))]);
|
||||
let p = red.get(6, 12);
|
||||
assert!(p[0] > 0.05 && p[1] < 1e-4 && p[2] < 1e-4, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ pub struct GrainLook {
|
|||
/// Grain time (frames of grain at the animation speed) and whether it interpolates.
|
||||
pub frame: f32,
|
||||
pub seed: u32,
|
||||
/// Color ▸ Red / Green / Blue Balance.
|
||||
pub balance: [f32; 3],
|
||||
}
|
||||
|
||||
impl GrainLook {
|
||||
|
|
@ -84,7 +86,28 @@ impl GrainLook {
|
|||
midpoint: pr.get("application/midpoint").map(Value::as_f64).unwrap_or(0.5).clamp(0.01, 0.99) as f32,
|
||||
frame: if smooth && ft.fract() != 0.0 { ft as f32 } else { ft.floor() as f32 },
|
||||
seed: (pr.f("animation/randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x9e37),
|
||||
balance: ch("color/", "redBalance", "greenBalance", "blueBalance"),
|
||||
}
|
||||
.with_preset(pr.e("preset"))
|
||||
}
|
||||
|
||||
/// Apply Add Grain's Preset (a film-stock look on top of the controls).
|
||||
fn with_preset(mut self, preset: u32) -> GrainLook {
|
||||
// (intensity ×, size ×, softness +, saturation ×, monochrome)
|
||||
let (ki, ks, soft, ksat, mono) = match preset {
|
||||
1 => (0.6, 0.7, 0.2, 0.6, false),
|
||||
2 => (1.3, 1.8, 0.4, 0.8, false),
|
||||
3 => (1.8, 2.5, 0.8, 0.5, false),
|
||||
4 => (1.0, 0.6, 0.0, 1.0, false),
|
||||
5 => (1.6, 1.4, 0.3, 1.0, true),
|
||||
_ => return self,
|
||||
};
|
||||
self.intensity *= ki;
|
||||
self.size *= ks;
|
||||
self.softness += soft;
|
||||
self.saturation *= ksat;
|
||||
self.mono |= mono;
|
||||
self
|
||||
}
|
||||
|
||||
/// Zero-mean noise planes (one, or one per channel) of grain `size` pixels (times each
|
||||
|
|
@ -129,7 +152,7 @@ impl GrainLook {
|
|||
let l = luminance(self.tint[0], self.tint[1], self.tint[2]).max(1e-3);
|
||||
g = [0, 1, 2].map(|k| g[k] * ((1.0 - self.tint_amount) + self.tint_amount * self.tint[k] / l));
|
||||
}
|
||||
g
|
||||
[g[0] * self.balance[0], g[1] * self.balance[1], g[2] * self.balance[2]]
|
||||
}
|
||||
|
||||
/// Combine source channel `c` with signed grain `n` by the Blending Mode.
|
||||
|
|
@ -171,6 +194,9 @@ pub(crate) fn grain_params() -> (Vec<crate::ParamSpec>, Vec<crate::ParamSpec>, V
|
|||
p("color/saturation", "Saturation", num(1.0), mult()),
|
||||
p("color/tintAmount", "Tint Amount", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("color/tintColor", "Tint Color", crate::col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("color/redBalance", "Red Balance", num(1.0), mult()),
|
||||
p("color/greenBalance", "Green Balance", num(1.0), mult()),
|
||||
p("color/blueBalance", "Blue Balance", num(1.0), mult()),
|
||||
];
|
||||
let application = vec![
|
||||
p("application/blendingMode", "Blending Mode", Value::Enum(0), popup(&GRAIN_BLEND_MODES)),
|
||||
|
|
@ -187,27 +213,124 @@ pub(crate) fn grain_params() -> (Vec<crate::ParamSpec>, Vec<crate::ParamSpec>, V
|
|||
(tweaking, color, application, animation)
|
||||
}
|
||||
|
||||
/// Add Grain's Viewing Mode options.
|
||||
pub(crate) const ADD_GRAIN_VIEWS: [&str; 3] = ["Preview", "Blending Matte", "Final Output"];
|
||||
/// Add Grain's Preset options (our own film-stock looks).
|
||||
pub(crate) const GRAIN_PRESETS: [&str; 6] = ["None", "Fine 35mm", "Coarse 16mm", "Vintage 8mm", "Video Noise", "Monochrome High Speed"];
|
||||
|
||||
/// The Preview Region controls of the grain effects (centre, size, box display).
|
||||
pub(crate) fn preview_params() -> Vec<crate::ParamSpec> {
|
||||
vec![
|
||||
p("previewRegion/center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point),
|
||||
p("previewRegion/width", "Width", num(200.0), slider(1.0, 10000.0, 1.0, 1000.0, 0)),
|
||||
p("previewRegion/height", "Height", num(200.0), slider(1.0, 10000.0, 1.0, 1000.0, 0)),
|
||||
p("previewRegion/showBox", "Show Box", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("previewRegion/boxColor", "Box Color", crate::col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
]
|
||||
}
|
||||
|
||||
/// Preview viewing mode: the processed pixels inside the Preview Region, the original
|
||||
/// outside, and (Show Box) the region's outline.
|
||||
pub(crate) fn preview_compose(ctx: &EffectCtx, b: &mut Buf, orig: &Image) {
|
||||
let c = b.to_px(ctx.params.v2("previewRegion/center"));
|
||||
let (hw, hh) = (ctx.params.f("previewRegion/width") * b.scale * 0.5, ctx.params.f("previewRegion/height") * b.scale * 0.5);
|
||||
let (x0, x1, y0, y1) = (c.0 - hw, c.0 + hw, c.1 - hh, c.1 + hh);
|
||||
let show = ctx.params.b("previewRegion/showBox");
|
||||
let col = ctx.params.color("previewRegion/boxColor");
|
||||
let w = b.img.width as usize;
|
||||
b.img.data.par_iter_mut().zip(orig.data.par_iter()).enumerate().for_each(|(i, (px, o))| {
|
||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
let inside = x >= x0 && x < x1 && y >= y0 && y < y1;
|
||||
if !inside {
|
||||
*px = *o;
|
||||
}
|
||||
if show {
|
||||
let near = |v: f64, e: f64| (v - e).abs() < 0.75;
|
||||
let on = ((near(x, x0) || near(x, x1)) && y >= y0 - 0.75 && y < y1 + 0.75) || ((near(y, y0) || near(y, y1)) && x >= x0 - 0.75 && x < x1 + 0.75);
|
||||
if on {
|
||||
*px = [col[0], col[1], col[2], 1.0];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Boxes (x, y, size in buffer px) where grain is sampled: the flattest of a grid of
|
||||
/// candidate boxes (real grain shows best where the picture has no detail).
|
||||
pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize, usize, usize)> {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let size = size.clamp(3, w.min(h).max(3));
|
||||
if w < size || h < size || count == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let low = Plane::luma(img);
|
||||
let step = (size / 2).max(1);
|
||||
let mut cands: Vec<(f32, usize, usize)> = Vec::new();
|
||||
for y in (0..=h - size).step_by(step) {
|
||||
for x in (0..=w - size).step_by(step) {
|
||||
// Variance of the picture inside the box (the flattest boxes hold the least detail).
|
||||
let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true);
|
||||
for yy in y..y + size {
|
||||
for xx in x..x + size {
|
||||
let v = low.data[yy * w + xx];
|
||||
s += v;
|
||||
sq += v * v;
|
||||
n += 1.0;
|
||||
opaque &= img.data[yy * w + xx][3] > 0.5;
|
||||
}
|
||||
}
|
||||
if opaque {
|
||||
cands.push((sq / n - (s / n).powi(2), x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
cands.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)).then(a.1.cmp(&b.1)));
|
||||
cands.into_iter().take(count).map(|(_, x, y)| (x, y, size)).collect()
|
||||
}
|
||||
|
||||
/// Draw sample boxes' outlines (Noise Samples viewing mode).
|
||||
pub(crate) fn draw_boxes(img: &mut Image, boxes: &[(usize, usize, usize)], c: [f32; 4]) {
|
||||
for &(x, y, s) in boxes {
|
||||
for k in 0..s {
|
||||
for (px, py) in [(x + k, y), (x + k, y + s - 1), (x, y + k), (x + s - 1, y + k)] {
|
||||
img.set(px as u32, py as u32, [c[0], c[1], c[2], 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Add Grain ----
|
||||
|
||||
fn add_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let look = GrainLook::from_params(ctx);
|
||||
if look.intensity <= 0.0 {
|
||||
// Viewing Mode (Final Output for projects saved before it existed).
|
||||
let view = ctx.params.get("viewingMode").map(|v| v.as_enum()).unwrap_or(2);
|
||||
if look.intensity <= 0.0 && view != 1 {
|
||||
return b;
|
||||
}
|
||||
let orig = b.img.clone();
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let planes = look.planes(w, h, look.size * b.scale as f32, b.scale, 101);
|
||||
let amp = look.intensity * 0.25;
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let (c, a) = unpremul(*px);
|
||||
let weight = look.weight(c);
|
||||
if view == 1 {
|
||||
// Blending Matte: where (and how strongly) the grain applies.
|
||||
let v = weight.clamp(0.0, 1.0);
|
||||
*px = [v, v, v, 1.0];
|
||||
return;
|
||||
}
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let raw = if look.mono { [planes[0].data[i]; 3] } else { [planes[0].data[i], planes[1].data[i], planes[2].data[i]] };
|
||||
let g = look.color(raw);
|
||||
let weight = look.weight(c);
|
||||
let o = [0, 1, 2].map(|k| look.blend(c[k], g[k] * amp * look.channel_intensity[k] * weight));
|
||||
*px = premul(o, a);
|
||||
});
|
||||
if view == 0 {
|
||||
preview_compose(ctx, &mut b, &orig);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
|
|
@ -333,18 +456,104 @@ fn dust_scratches(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
b
|
||||
}
|
||||
|
||||
/// Remove Grain's Viewing Mode options.
|
||||
pub(crate) const REMOVE_GRAIN_VIEWS: [&str; 4] = ["Preview", "Noise Samples", "Blending Matte", "Final Output"];
|
||||
|
||||
/// Grain sampling controls (Match Grain, Remove Grain): how many flat boxes of what size the
|
||||
/// grain is measured in.
|
||||
pub(crate) fn sampling_params() -> Vec<crate::ParamSpec> {
|
||||
vec![
|
||||
p("sampling/samplingProcess", "Sampling Process", Value::Enum(0), popup(&["Automatic"])),
|
||||
p("sampling/numberOfSamples", "Number of Samples", num(80.0), slider(1.0, 500.0, 1.0, 200.0, 0)),
|
||||
p("sampling/sampleSize", "Sample Size", num(9.0), slider(3.0, 64.0, 3.0, 32.0, 0)),
|
||||
p("sampling/sampleBoxColor", "Sample Box Color", crate::col(1.0, 1.0, 0.0), ParamUi::Color),
|
||||
]
|
||||
}
|
||||
|
||||
/// The sample boxes for the effect's Sampling settings.
|
||||
pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(usize, usize, usize)> {
|
||||
let n = ctx.params.get("sampling/numberOfSamples").map(Value::as_f64).unwrap_or(80.0).clamp(1.0, 500.0) as usize;
|
||||
let s = (ctx.params.get("sampling/sampleSize").map(Value::as_f64).unwrap_or(9.0) * scale).round().max(3.0) as usize;
|
||||
sample_boxes(img, n, s)
|
||||
}
|
||||
|
||||
fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let amt = pr.f("noiseReductionSettings/noiseReduction") as f32;
|
||||
let um_amount = pr.f("unsharpMask/amount") as f32 / 100.0;
|
||||
if amt <= 0.0 && um_amount <= 0.0 {
|
||||
let view = pr.get("viewingMode").map(|v| v.as_enum()).unwrap_or(3);
|
||||
let boxes = sampling_boxes(ctx, &b.img, b.scale);
|
||||
if view == 1 {
|
||||
let c = pr.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]);
|
||||
draw_boxes(&mut b.img, &boxes, c);
|
||||
return b;
|
||||
}
|
||||
if view == 2 {
|
||||
b.img.data.iter_mut().for_each(|p| *p = [1.0, 1.0, 1.0, 1.0]);
|
||||
return b;
|
||||
}
|
||||
let temporal = pr.b("temporalFiltering/enabled");
|
||||
if amt <= 0.0 && um_amount <= 0.0 && !temporal {
|
||||
return b;
|
||||
}
|
||||
let before = b.img.clone();
|
||||
// Temporal Filtering: steady pixels average with the neighbouring frames (grain changes
|
||||
// every frame, the picture does not); Motion Sensitivity keeps moving areas out of it.
|
||||
if temporal {
|
||||
let fps = ctx.fps();
|
||||
let host = ctx.env.host;
|
||||
let at = |t: f64| host.and_then(|h| h.self_at(t, ctx.env.effect_index)).filter(|o| o.img.width == b.img.width && o.img.height == b.img.height);
|
||||
let (prev, next) = (at(ctx.time - 1.0 / fps), at(ctx.time + 1.0 / fps));
|
||||
let k = (pr.get("temporalFiltering/amount").map(Value::as_f64).unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let sens = pr.get("temporalFiltering/motionSensitivity").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0) as f32;
|
||||
let thr = 0.02 + (1.0 - sens) * 0.2;
|
||||
if prev.is_some() || next.is_some() {
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let mut acc = *px;
|
||||
let mut n = 1.0;
|
||||
for o in [&prev, &next].into_iter().flatten() {
|
||||
let q = o.img.data[i];
|
||||
let d = (0..3).map(|c| (q[c] - px[c]).abs()).fold(0.0f32, f32::max);
|
||||
if d < thr {
|
||||
let wgt = 1.0 - d / thr;
|
||||
for c in 0..4 {
|
||||
acc[c] += q[c] * wgt;
|
||||
}
|
||||
n += wgt;
|
||||
}
|
||||
}
|
||||
for c in 0..4 {
|
||||
px[c] += (acc[c] / n - px[c]) * k;
|
||||
}
|
||||
});
|
||||
}
|
||||
if amt <= 0.0 && um_amount <= 0.0 {
|
||||
if view == 0 {
|
||||
preview_compose(ctx, &mut b, &before);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
}
|
||||
// The grain level measured in the sample boxes scales the smoothing.
|
||||
let level = {
|
||||
let mut m = vec![false; b.img.data.len()];
|
||||
let w = b.img.width as usize;
|
||||
for &(x, y, s) in &boxes {
|
||||
for yy in y..y + s {
|
||||
for xx in x..x + s {
|
||||
m[yy * w + xx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let st = crate::noise2::grain_stats_masked(&b.img, Some(&m));
|
||||
let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0;
|
||||
if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) }
|
||||
};
|
||||
let passes = pr.f("noiseReductionSettings/passes").round().clamp(1.0, 8.0) as usize;
|
||||
let multichannel = pr.e("noiseReductionSettings/mode") == 0;
|
||||
let texture = (pr.f("fineTuning/texture") as f32).clamp(0.0, 1.0);
|
||||
let r = (2.0 * b.scale).round().max(1.0) as usize;
|
||||
let eps = (0.02 * amt).powi(2);
|
||||
let eps = (0.02 * amt * level).powi(2);
|
||||
let orig = split(&b.img);
|
||||
let mut ch = orig.clone();
|
||||
if amt > 0.0 {
|
||||
|
|
@ -387,6 +596,9 @@ fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
p[3] = a;
|
||||
});
|
||||
b.img = out;
|
||||
if view == 0 {
|
||||
preview_compose(ctx, &mut b, &before);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
|
|
@ -533,7 +745,9 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
vec![
|
||||
{
|
||||
let (tweaking, color, application, animation) = grain_params();
|
||||
spec("ec.noise.addgrain", "Add Grain", [tweaking, color, application, animation].into_iter().flatten().collect(), add_grain)
|
||||
let head =
|
||||
vec![p("viewingMode", "Viewing Mode", Value::Enum(2), popup(&ADD_GRAIN_VIEWS)), p("preset", "Preset", Value::Enum(0), popup(&GRAIN_PRESETS))];
|
||||
spec("ec.noise.addgrain", "Add Grain", [head, preview_params(), tweaking, color, application, animation].into_iter().flatten().collect(), add_grain)
|
||||
},
|
||||
spec(
|
||||
"ec.noise.median",
|
||||
|
|
@ -557,15 +771,26 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
spec(
|
||||
"ec.noise.removegrain",
|
||||
"Remove Grain",
|
||||
vec![
|
||||
p("noiseReductionSettings/noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)),
|
||||
p("noiseReductionSettings/passes", "Passes", num(1.0), slider(1.0, 8.0, 1.0, 4.0, 0)),
|
||||
p("noiseReductionSettings/mode", "Mode", Value::Enum(0), popup(&["Multichannel", "Single Channel"])),
|
||||
p("fineTuning/texture", "Texture", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("unsharpMask/amount", "Amount", num(0.0), slider(0.0, 500.0, 0.0, 200.0, 1)),
|
||||
p("unsharpMask/radius", "Radius", num(1.0), slider(0.1, 100.0, 0.1, 10.0, 1)),
|
||||
p("unsharpMask/threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
|
||||
],
|
||||
[
|
||||
vec![p("viewingMode", "Viewing Mode", Value::Enum(3), popup(&REMOVE_GRAIN_VIEWS))],
|
||||
preview_params(),
|
||||
vec![
|
||||
p("noiseReductionSettings/noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)),
|
||||
p("noiseReductionSettings/passes", "Passes", num(1.0), slider(1.0, 8.0, 1.0, 4.0, 0)),
|
||||
p("noiseReductionSettings/mode", "Mode", Value::Enum(0), popup(&["Multichannel", "Single Channel"])),
|
||||
p("fineTuning/texture", "Texture", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("unsharpMask/amount", "Amount", num(0.0), slider(0.0, 500.0, 0.0, 200.0, 1)),
|
||||
p("unsharpMask/radius", "Radius", num(1.0), slider(0.1, 100.0, 0.1, 10.0, 1)),
|
||||
p("unsharpMask/threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)),
|
||||
p("temporalFiltering/enabled", "Enabled", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("temporalFiltering/amount", "Amount", num(100.0), pct()),
|
||||
p("temporalFiltering/motionSensitivity", "Motion Sensitivity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
],
|
||||
sampling_params(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
remove_grain,
|
||||
),
|
||||
spec(
|
||||
|
|
@ -768,6 +993,71 @@ mod tests {
|
|||
assert!(tinted.img.data.iter().any(|p| (p[0] - 0.4).abs() > 1e-3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grain_preview_presets_balance_and_views() {
|
||||
let base = gray(64, 64, 0.5);
|
||||
let grain = [("tweaking/intensity", num(3.0))];
|
||||
let with = |extra: &[(&str, Value)]| run("ec.noise.addgrain", &[grain.as_slice(), extra].concat(), base.clone(), 0.0).img;
|
||||
// Preview: grain only inside the region; its box outline drawn.
|
||||
let prev = with(&[
|
||||
("viewingMode", Value::Enum(0)),
|
||||
("previewRegion/center", Value::Vec2([32.0, 32.0])),
|
||||
("previewRegion/width", num(20.0)),
|
||||
("previewRegion/height", num(20.0)),
|
||||
]);
|
||||
assert_eq!(prev.get(2, 2), base.get(2, 2));
|
||||
assert_ne!(prev.get(32, 32), base.get(32, 32));
|
||||
assert_eq!(prev.get(22, 32), [1.0, 1.0, 1.0, 1.0], "box outline");
|
||||
// Blending Matte shows the tonal weight (1 at mid grey with the defaults).
|
||||
assert_eq!(with(&[("viewingMode", Value::Enum(1))]).get(5, 5), [1.0, 1.0, 1.0, 1.0]);
|
||||
// Presets and channel balance change the grain.
|
||||
let plain = with(&[]);
|
||||
assert_ne!(with(&[("preset", Value::Enum(3))]), plain);
|
||||
let no_red = with(&[("color/redBalance", num(0.0))]);
|
||||
assert!(no_red.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-6));
|
||||
// Sample boxes sit in the flat part of a picture.
|
||||
let mut half = gray(64, 64, 0.5);
|
||||
for y in 0..64 {
|
||||
for x in 32..64 {
|
||||
half.set(x, y, if (x + y) % 2 == 0 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] });
|
||||
}
|
||||
}
|
||||
let boxes = sample_boxes(&half, 4, 8);
|
||||
assert_eq!(boxes.len(), 4);
|
||||
assert!(boxes.iter().all(|(x, _, s)| x + s <= 32), "{boxes:?}");
|
||||
// Remove Grain's Noise Samples view draws them.
|
||||
let shown = run("ec.noise.removegrain", &[("viewingMode", Value::Enum(1))], gray(64, 64, 0.5), 0.0).img;
|
||||
assert!(shown.data.iter().any(|p| p == &[1.0, 1.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_grain_temporal_filtering_averages_steady_frames() {
|
||||
/// Flat grey with per-frame grain.
|
||||
struct Grainy;
|
||||
impl crate::EffectHost for Grainy {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
|
||||
Some(Buf { img: frame((t * 10.0).round() as u32), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
}
|
||||
fn frame(f: u32) -> Image {
|
||||
crate::util::gen_image(32, 32, |x, y| {
|
||||
let n = (hash1(x as u32, y as u32, f.wrapping_mul(977)) - 0.5) * 0.06;
|
||||
[0.5 + n, 0.5 + n, 0.5 + n, 1.0]
|
||||
})
|
||||
}
|
||||
let env = crate::EffectEnv { host: Some(&Grainy), frame_rate: 10.0, ..Default::default() };
|
||||
let vals = [("noiseReductionSettings/noiseReduction", num(0.0)), ("temporalFiltering/enabled", Value::Bool(true))];
|
||||
let out = crate::run_fx("ec.noise.removegrain", &vals, frame(5), 0.5, env).img;
|
||||
let dev = |i: &Image| i.data.iter().map(|p| (p[0] - 0.5).abs()).sum::<f32>();
|
||||
assert!(dev(&out) < dev(&frame(5)) * 0.75, "{} vs {}", dev(&out), dev(&frame(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_alpha_cycle_repeats() {
|
||||
let at = |phase: f64| {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use effectcraft_project::ParamUi;
|
|||
use effectcraft_raster::Image;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::noise::{GrainLook, fbm, grain_params, median_image};
|
||||
use crate::noise::{GrainLook, draw_boxes, fbm, grain_params, median_image, preview_params, sampling_boxes, sampling_params};
|
||||
use crate::util::{Plane, gauss_plane, layer_or_self, premul, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
|
||||
|
||||
|
|
@ -29,7 +29,14 @@ pub(crate) struct GrainStats {
|
|||
pub size: f32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn grain_stats(img: &Image) -> GrainStats {
|
||||
grain_stats_masked(img, None)
|
||||
}
|
||||
|
||||
/// [`grain_stats`] over the pixels `mask` selects (all opaque pixels without one).
|
||||
pub(crate) fn grain_stats_masked(img: &Image, mask: Option<&[bool]>) -> GrainStats {
|
||||
let sel = |i: usize| mask.is_none_or(|m| m[i]);
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if w < 3 || h < 3 {
|
||||
return GrainStats::default();
|
||||
|
|
@ -44,7 +51,7 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats {
|
|||
let hp: Vec<f32> = pl.data.iter().zip(low.data.iter()).map(|(a, b)| a - b).collect();
|
||||
let (mut sum, mut sq, mut n) = (0.0f64, 0.0f64, 0.0f64);
|
||||
for (i, v) in hp.iter().enumerate() {
|
||||
if straight[i].1 > 0.5 {
|
||||
if straight[i].1 > 0.5 && sel(i) {
|
||||
sum += *v as f64;
|
||||
sq += (*v as f64) * (*v as f64);
|
||||
n += 1.0;
|
||||
|
|
@ -57,7 +64,7 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats {
|
|||
for y in 0..h {
|
||||
for x in 0..w - 1 {
|
||||
let i = y * w + x;
|
||||
if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 {
|
||||
if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 && sel(i) && sel(i + 1) {
|
||||
rho_num += (hp[i] * hp[i + 1]) as f64;
|
||||
rho_den += (hp[i] * hp[i]) as f64;
|
||||
}
|
||||
|
|
@ -68,15 +75,45 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats {
|
|||
GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 }
|
||||
}
|
||||
|
||||
/// Match Grain's Viewing Mode options.
|
||||
const MATCH_GRAIN_VIEWS: [&str; 5] = ["Preview", "Noise Samples", "Compensation Samples", "Blending Matte", "Final Output"];
|
||||
|
||||
fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
if ctx.params.get("noiseSourceLayer").and_then(Value::as_layer).is_none() {
|
||||
return b;
|
||||
}
|
||||
// MATCH_GRAIN_VIEWS order.
|
||||
let view = ctx.params.e("viewingMode");
|
||||
let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false);
|
||||
let src_stats = grain_stats(&source);
|
||||
let src_boxes = sampling_boxes(ctx, &source, b.scale);
|
||||
let own_boxes = sampling_boxes(ctx, &b.img, b.scale);
|
||||
let box_col = ctx.params.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]);
|
||||
if view == 1 || view == 2 {
|
||||
// Noise Samples (on the source) / Compensation Samples (on this layer).
|
||||
let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), own_boxes) };
|
||||
draw_boxes(&mut img, &boxes, box_col);
|
||||
b.img = img;
|
||||
return b;
|
||||
}
|
||||
let mask_of = |img: &Image, boxes: &[(usize, usize, usize)]| -> Option<Vec<bool>> {
|
||||
if boxes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let w = img.width as usize;
|
||||
let mut m = vec![false; img.data.len()];
|
||||
for &(x, y, s) in boxes {
|
||||
for yy in y..y + s {
|
||||
for xx in x..x + s {
|
||||
m[yy * w + xx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(m)
|
||||
};
|
||||
let src_stats = grain_stats_masked(&source, mask_of(&source, &src_boxes).as_deref());
|
||||
let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32;
|
||||
let own = if comp > 0.0 { grain_stats(&b.img) } else { GrainStats::default() };
|
||||
let own = if comp > 0.0 { grain_stats_masked(&b.img, mask_of(&b.img, &own_boxes).as_deref()) } else { GrainStats::default() };
|
||||
let orig = b.img.clone();
|
||||
let look = GrainLook::from_params(ctx);
|
||||
let amp: [f32; 3] = [0, 1, 2].map(|k| {
|
||||
let s = src_stats.std[k];
|
||||
|
|
@ -100,11 +137,7 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let g = look.color(raw);
|
||||
let weight = look.weight(c);
|
||||
match view {
|
||||
1 => {
|
||||
let o = [0, 1, 2].map(|k| (0.5 + g[k] * amp[k]).clamp(0.0, 1.0));
|
||||
*px = [o[0], o[1], o[2], 1.0];
|
||||
}
|
||||
2 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0],
|
||||
3 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0],
|
||||
_ => {
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
|
|
@ -114,6 +147,9 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
}
|
||||
}
|
||||
});
|
||||
if view == 0 {
|
||||
crate::noise::preview_compose(ctx, &mut b, &orig);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
|
|
@ -214,13 +250,13 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
"ec.noise.matchgrain",
|
||||
"Match Grain",
|
||||
vec![
|
||||
p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])),
|
||||
p("viewingMode", "Viewing Mode", Value::Enum(4), popup(&MATCH_GRAIN_VIEWS)),
|
||||
p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("noiseSourceLayerSource", "Noise Source Layer Source", Value::Enum(0), ParamUi::Hidden),
|
||||
p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()),
|
||||
]
|
||||
.into_iter()
|
||||
.chain([tweaking, color, application, animation].into_iter().flatten())
|
||||
.chain([preview_params(), sampling_params(), tweaking, color, application, animation].into_iter().flatten())
|
||||
.collect(),
|
||||
match_grain,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -431,7 +431,7 @@ mod tests {
|
|||
fn noise_types_differ_and_block_is_piecewise_constant() {
|
||||
let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(10.0))]);
|
||||
// Block noise: neighbours inside one 10 px cell share a value.
|
||||
assert_eq!(a.get(1, 1), a.get(2, 2));
|
||||
assert_eq!(a.get(4, 4), a.get(5, 5));
|
||||
let others: Vec<Image> = (1..4).map(|t| run("ec.noise.fractal", &[("noiseType", Value::Enum(t)), ("transform/scale", num(10.0))])).collect();
|
||||
assert_ne!(others[0], others[1]);
|
||||
assert_ne!(others[1], others[2]);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -61,6 +61,41 @@ fn cartoon(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
} else {
|
||||
b.img.clone()
|
||||
};
|
||||
// Advanced ▸ Edge Enhancement: one shock-filter step. Each pixel samples from the side of
|
||||
// the nearest edge it belongs to (along the luma gradient, by the sign of the Laplacian):
|
||||
// positive values sharpen the transitions, negative ones soften them.
|
||||
let enh = ctx.params.f("advanced/edgeEnhancement") / 100.0;
|
||||
let smoothed = if enh < 0.0 {
|
||||
// Softening: blend towards a blur of the picture.
|
||||
let s = 1.5 * b.scale;
|
||||
let blur = effectcraft_raster::gaussian_blur(&smoothed, s, s, true);
|
||||
let k = (-enh) as f32;
|
||||
let mut out = smoothed;
|
||||
out.data.par_iter_mut().zip(blur.data.par_iter()).for_each(|(p, q)| (0..4).for_each(|c| p[c] += (q[c] - p[c]) * k));
|
||||
out
|
||||
} else if enh > 0.0 {
|
||||
let l = Plane::luma(&smoothed);
|
||||
let (w, h) = (l.w as i64, l.h as i64);
|
||||
let s = enh * 2.0 * b.scale;
|
||||
let src = smoothed.clone();
|
||||
let mut out = smoothed;
|
||||
out.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let (x, y) = ((i as i64) % w, (i as i64) / w);
|
||||
let g = |dx: i64, dy: i64| l.get_clamped(x + dx, y + dy);
|
||||
let (gx, gy) = ((g(1, 0) - g(-1, 0)) * 0.5, (g(0, 1) - g(0, -1)) * 0.5);
|
||||
let lap = g(1, 0) + g(-1, 0) + g(0, 1) + g(0, -1) - 4.0 * g(0, 0);
|
||||
let m = (gx * gx + gy * gy).sqrt();
|
||||
if m < 1e-4 || lap.abs() < 1e-5 || y >= h {
|
||||
return;
|
||||
}
|
||||
let k = -lap.signum() as f64 * s / m as f64;
|
||||
let (sx, sy) = (x as f64 + 0.5 + gx as f64 * k, y as f64 + 0.5 + gy as f64 * k);
|
||||
*px = src.sample_bilinear_clamped(sx, sy);
|
||||
});
|
||||
out
|
||||
} else {
|
||||
smoothed
|
||||
};
|
||||
let luma = Plane::luma(&smoothed);
|
||||
let edges = if render >= 1 {
|
||||
let mag = sobel(&luma);
|
||||
|
|
@ -543,6 +578,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("edge/edgeWidth", "Width", num(1.5), slider(0.0, 10.0, 0.0, 5.0, 2)),
|
||||
p("edge/edgeSoftness", "Softness", num(60.0), pct()),
|
||||
p("edge/edgeOpacity", "Opacity", num(100.0), pct()),
|
||||
p("advanced/edgeEnhancement", "Edge Enhancement", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
|
||||
p("advanced/edgeBlackLevel", "Edge Black Level", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("advanced/edgeContrast", "Edge Contrast", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
],
|
||||
|
|
@ -993,4 +1029,30 @@ mod tests {
|
|||
assert_eq!(cyc(0.0), cyc(360.0));
|
||||
assert_ne!(cyc(0.0), cyc(180.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cartoon_edge_enhancement_sharpens_or_softens() {
|
||||
// A soft 8-pixel ramp from black to white.
|
||||
let mut img = Image::new(32, 4);
|
||||
for y in 0..4 {
|
||||
for x in 0..32 {
|
||||
let v = 0.5 + 0.5 * ((x as f32 - 15.5) / 4.0).tanh();
|
||||
img.set(x, y, [v, v, v, 1.0]);
|
||||
}
|
||||
}
|
||||
let fill = |e: f64| {
|
||||
let v = [
|
||||
("render", Value::Enum(0)),
|
||||
("detailRadius", num(0.0)),
|
||||
("fill/shadingSteps", num(64.0)),
|
||||
("fill/shadingSmoothness", num(100.0)),
|
||||
("advanced/edgeEnhancement", num(e)),
|
||||
];
|
||||
run_at("ec.stylize.cartoon", &v, img.clone(), 0.0).img
|
||||
};
|
||||
let steep = |o: &Image| (0..31).map(|x| (o.get(x + 1, 2)[0] - o.get(x, 2)[0]).abs()).fold(0.0f32, f32::max);
|
||||
let (base, sharp, soft) = (fill(0.0), fill(100.0), fill(-100.0));
|
||||
assert!(steep(&sharp) > steep(&base) + 0.02, "{} vs {}", steep(&sharp), steep(&base));
|
||||
assert!(steep(&soft) < steep(&base) + 1e-4, "{} vs {}", steep(&soft), steep(&base));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ fn glyph_src(c: char) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// A glyph's strokes on the 4 × 6 grid (Particle Playground's text particles).
|
||||
pub(crate) fn glyph_strokes(c: char) -> Vec<Vec<[f64; 2]>> {
|
||||
parse_glyph(c)
|
||||
}
|
||||
|
||||
fn parse_glyph(c: char) -> Vec<Vec<[f64; 2]>> {
|
||||
glyph_src(c)
|
||||
.split(';')
|
||||
|
|
@ -503,49 +508,112 @@ fn path_text(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
closed = false;
|
||||
}
|
||||
let len = poly_length(&path, closed);
|
||||
let text: String = pr.s("text").chars().take(g("advanced", "visibleCharacters").as_f64().max(0.0) as usize).collect();
|
||||
let visible = g("advanced", "visibleCharacters").as_f64().max(0.0);
|
||||
let all: Vec<char> = pr.s("text").chars().collect();
|
||||
let n_all = all.len();
|
||||
let size = (g("character", "size").as_f64() * b.scale).max(0.5);
|
||||
let u = unit(size);
|
||||
let tracking = g("character", "tracking").as_f64() * b.scale;
|
||||
let lm = g("paragraph", "leftMargin").as_f64() * b.scale;
|
||||
let rm = g("paragraph", "rightMargin").as_f64() * b.scale;
|
||||
let shift = g("paragraph", "baselineShift").as_f64() * b.scale;
|
||||
let line_spacing = pr.get("paragraph/lineSpacing").map(Value::as_f64).unwrap_or(size / b.scale * 1.2) * b.scale;
|
||||
let rot = g("character/orientation", "characterRotation").as_f64().to_radians();
|
||||
let n = text.chars().count();
|
||||
let advs: Vec<f64> = text.chars().map(|c| advance(c, true) * u).collect();
|
||||
let natural: f64 = advs.iter().sum::<f64>() + tracking * (n.max(1) as f64 - 1.0);
|
||||
let avail = (len - lm - rm).max(0.0);
|
||||
let (mut cursor, extra) = match g("paragraph", "alignment").as_enum() {
|
||||
1 => (len - rm - natural, 0.0),
|
||||
2 => (lm + (avail - natural) * 0.5, 0.0),
|
||||
3 => (lm, if n > 1 { (avail - natural) / (n as f64 - 1.0) } else { 0.0 }),
|
||||
_ => (lm, 0.0),
|
||||
let shear = pr.f("character/orientation/characterShear").to_radians().tan();
|
||||
let hshear = pr.f("character/horizontalShear").to_radians().tan();
|
||||
let sx = pr.get("character/horizontalScale").map(Value::as_f64).unwrap_or(100.0) / 100.0;
|
||||
let sy = pr.get("character/verticalScale").map(Value::as_f64).unwrap_or(100.0) / 100.0;
|
||||
// Kerning: "index:value …" — the gap after character `index` (0-based) in 1/1000 em.
|
||||
let kerning: Vec<(usize, f64)> = pr
|
||||
.s("character/kerning")
|
||||
.split_whitespace()
|
||||
.filter_map(|t| {
|
||||
let (i, v) = t.split_once(':')?;
|
||||
Some((i.trim().parse().ok()?, v.trim().parse().ok()?))
|
||||
})
|
||||
.collect();
|
||||
let kern = |i: usize| kerning.iter().filter(|(k, _)| *k == i).map(|(_, v)| v / 1000.0 * size).sum::<f64>();
|
||||
// Jitter: per character, re-randomised every frame.
|
||||
let jit = |id: &str| pr.f(&format!("advanced/jitterSettings/{id}"));
|
||||
let (j_base, j_kern, j_rot, j_scale) =
|
||||
(jit("baselineJitterMax") * b.scale, jit("kerningJitterMax") * b.scale, jit("rotationJitterMax").to_radians(), jit("scaleJitterMax") / 100.0);
|
||||
let frame = (ctx.time * ctx.fps()).floor() as i64 as u32;
|
||||
let rnd = |i: usize, k: u32| hash1(i as u32, k.wrapping_add(frame.wrapping_mul(7919)), 0x7e17) as f64 * 2.0 - 1.0;
|
||||
// Fade Time: characters fade in over this share of the visible characters.
|
||||
let fade = pr.f("advanced/fadeTime").clamp(0.0, 100.0) / 100.0 * n_all.max(1) as f64;
|
||||
let char_alpha = |i: usize| -> f32 {
|
||||
if fade <= 0.0 { if (i as f64) < visible.floor() { 1.0 } else { 0.0 } } else { ((visible - i as f64) / fade).clamp(0.0, 1.0) as f32 }
|
||||
};
|
||||
let mut polys = Vec::new();
|
||||
for (c, adv) in text.chars().zip(advs) {
|
||||
let mid = cursor + adv * 0.5;
|
||||
cursor += adv + tracking + extra;
|
||||
if !closed && (mid < 0.0 || mid > len) {
|
||||
continue;
|
||||
}
|
||||
let (q, d) = poly_point_at(&path, closed, if closed { mid.rem_euclid(len.max(1e-9)) } else { mid });
|
||||
let ang = d[1].atan2(d[0]) + rot;
|
||||
let (s, co) = ang.sin_cos();
|
||||
let left = ink_left(c, true);
|
||||
for st in parse_glyph(c) {
|
||||
polys.push(
|
||||
st.iter()
|
||||
.map(|g| {
|
||||
let lx = (g[0] - left - 0.8) * u - adv * 0.5 + 0.8 * u;
|
||||
let ly = (g[1] - 6.0) * u - shift;
|
||||
[q[0] + lx * co - ly * s, q[1] + lx * s + ly * co]
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
// Lines (separated by line breaks) stack along the path's normal by Line Spacing.
|
||||
let mut polys_by_alpha: Vec<(f32, Vec<Vec<[f64; 2]>>)> = Vec::new();
|
||||
let mut gi = 0usize;
|
||||
for (li, line) in all.split(|c| *c == '\n' || *c == '\r').enumerate() {
|
||||
let idx0 = gi;
|
||||
gi += line.len() + 1;
|
||||
let n = line.len();
|
||||
let advs: Vec<f64> = line.iter().map(|&c| advance(c, true) * u * sx).collect();
|
||||
let kerns: Vec<f64> = (0..n).map(|k| kern(idx0 + k) + if j_kern != 0.0 { rnd(idx0 + k, 2) * j_kern } else { 0.0 }).collect();
|
||||
let natural: f64 = advs.iter().sum::<f64>() + tracking * (n.max(1) as f64 - 1.0) + kerns.iter().take(n.saturating_sub(1)).sum::<f64>();
|
||||
let avail = (len - lm - rm).max(0.0);
|
||||
let (mut cursor, extra) = match g("paragraph", "alignment").as_enum() {
|
||||
1 => (len - rm - natural, 0.0),
|
||||
2 => (lm + (avail - natural) * 0.5, 0.0),
|
||||
3 => (lm, if n > 1 { (avail - natural) / (n as f64 - 1.0) } else { 0.0 }),
|
||||
_ => (lm, 0.0),
|
||||
};
|
||||
let line_off = li as f64 * line_spacing;
|
||||
for (k, (&c, adv)) in line.iter().zip(advs).enumerate() {
|
||||
let i = idx0 + k;
|
||||
let mid = cursor + adv * 0.5;
|
||||
cursor += adv + tracking + extra + kerns[k];
|
||||
let alpha = char_alpha(i);
|
||||
if alpha <= 0.0 || (!closed && (mid < 0.0 || mid > len)) {
|
||||
continue;
|
||||
}
|
||||
let (q, d) = poly_point_at(&path, closed, if closed { mid.rem_euclid(len.max(1e-9)) } else { mid });
|
||||
let ang = d[1].atan2(d[0]) + rot + if j_rot != 0.0 { rnd(i, 3) * j_rot } else { 0.0 };
|
||||
let (s, co) = ang.sin_cos();
|
||||
let js = if j_scale != 0.0 { (1.0 + rnd(i, 4) * j_scale).max(0.05) } else { 1.0 };
|
||||
let jb = if j_base != 0.0 { rnd(i, 1) * j_base } else { 0.0 };
|
||||
let left = ink_left(c, true);
|
||||
let set = match polys_by_alpha.iter_mut().find(|(a, _)| (*a - alpha).abs() < 1e-4) {
|
||||
Some((_, v)) => v,
|
||||
None => {
|
||||
polys_by_alpha.push((alpha, Vec::new()));
|
||||
&mut polys_by_alpha.last_mut().expect("pushed").1
|
||||
}
|
||||
};
|
||||
for st in parse_glyph(c) {
|
||||
set.push(
|
||||
st.iter()
|
||||
.map(|gl| {
|
||||
// Glyph space (baseline at 0, y down), then scale, shear and lift.
|
||||
let gx = ((gl[0] - left - 0.8) * u + 0.8 * u) * sx * js - adv * 0.5;
|
||||
let gy = (gl[1] - 6.0) * u * sy * js;
|
||||
let gx = gx - gy * (shear + hshear);
|
||||
let gy = gy - shift - jb + line_off;
|
||||
[q[0] + gx * co - gy * s, q[1] + gx * s + gy * co]
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let look = look_from(ctx, &b, "compositeOnOriginal");
|
||||
draw_text(&mut b, &polys, weight(size), &look);
|
||||
let mut look = look_from(ctx, &b, "compositeOnOriginal");
|
||||
let base_op = look.opacity;
|
||||
let mut first = true;
|
||||
for (alpha, polys) in &polys_by_alpha {
|
||||
look.opacity = base_op * alpha;
|
||||
draw_text(&mut b, polys, weight(size), &look);
|
||||
if first {
|
||||
// Later passes go over the first one.
|
||||
look.on_original = true;
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
if polys_by_alpha.is_empty() {
|
||||
draw_text(&mut b, &[], weight(size), &look);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
|
|
@ -806,12 +874,24 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("fillAndStroke/strokeWidth", "Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)),
|
||||
p("character/size", "Size", num(36.0), size()),
|
||||
p("character/tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)),
|
||||
// Kerning: "index:value" pairs, the gap after character `index` in 1/1000 em.
|
||||
p("character/kerning", "Kerning", Value::Str(String::new()), ParamUi::Text),
|
||||
p("character/orientation/characterRotation", "Character Rotation", num(0.0), ang()),
|
||||
p("character/orientation/characterShear", "Character Shear", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)),
|
||||
p("character/horizontalShear", "Horizontal Shear", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)),
|
||||
p("character/horizontalScale", "Horizontal Scale", num(100.0), slider(1.0, 1000.0, 10.0, 400.0, 1)),
|
||||
p("character/verticalScale", "Vertical Scale", num(100.0), slider(1.0, 1000.0, 10.0, 400.0, 1)),
|
||||
p("paragraph/alignment", "Alignment", Value::Enum(0), popup(&["Left", "Right", "Center", "Force"])),
|
||||
p("paragraph/leftMargin", "Left Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)),
|
||||
p("paragraph/rightMargin", "Right Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)),
|
||||
p("paragraph/lineSpacing", "Line Spacing", num(43.0), slider(-1000.0, 1000.0, 0.0, 200.0, 1)),
|
||||
p("paragraph/baselineShift", "Baseline Shift", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
|
||||
p("advanced/visibleCharacters", "Visible Characters", num(1024.0), slider(0.0, 1024.0, 0.0, 1024.0, 0)),
|
||||
p("advanced/fadeTime", "Fade Time", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("advanced/jitterSettings/baselineJitterMax", "Baseline Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||
p("advanced/jitterSettings/kerningJitterMax", "Kerning Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||
p("advanced/jitterSettings/rotationJitterMax", "Rotation Jitter Max", num(0.0), slider(0.0, 360.0, 0.0, 360.0, 1)),
|
||||
p("advanced/jitterSettings/scaleJitterMax", "Scale Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
|
||||
check("compositeOnOriginal", "Composite On Original", true),
|
||||
],
|
||||
path_text,
|
||||
|
|
@ -995,6 +1075,39 @@ mod tests {
|
|||
assert!(ink(&circle.img) > 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_text_kerning_scale_lines_fade_and_jitter() {
|
||||
let img = Image::new(160, 100);
|
||||
let line = [
|
||||
("pathOptions/shapeType", Value::Enum(3)),
|
||||
("pathOptions/controlPoints/vertex1", Value::Vec2([5.0, 40.0])),
|
||||
("pathOptions/controlPoints/vertex2", Value::Vec2([155.0, 40.0])),
|
||||
("character/size", num(20.0)),
|
||||
("text", Value::Str("AB".into())),
|
||||
];
|
||||
let with = |extra: &[(&str, Value)], t: f64| run("ec.obsolete.pathtext", &[line.as_slice(), extra].concat(), img.clone(), t).img;
|
||||
let right_edge = |i: &Image| (0..160).rev().find(|&x| (0..100).any(|y| i.get(x, y)[3] > 0.3)).unwrap_or(0);
|
||||
let base = with(&[], 0.0);
|
||||
// Kerning after the first character pushes the second one along.
|
||||
assert!(right_edge(&with(&[("character/kerning", Value::Str("0:500".into()))], 0.0)) >= right_edge(&base) + 8);
|
||||
// Horizontal Scale widens, shear changes the shapes.
|
||||
assert!(right_edge(&with(&[("character/horizontalScale", num(200.0))], 0.0)) > right_edge(&base) + 5);
|
||||
assert_ne!(with(&[("character/horizontalShear", num(30.0))], 0.0), base);
|
||||
// A second line sits Line Spacing below the first.
|
||||
let two = with(&[("text", Value::Str("AB\rCD".into())), ("paragraph/lineSpacing", num(30.0))], 0.0);
|
||||
let ink_rows = |i: &Image, y0: i64, y1: i64| (y0..y1).flat_map(|y| (0..160).map(move |x| (x, y))).map(|(x, y)| i.get(x, y)[3]).sum::<f32>();
|
||||
assert!(ink_rows(&two, 52, 72) > 5.0 && ink_rows(&base, 52, 72) < 0.5);
|
||||
// Fade Time: the character being revealed is partly transparent.
|
||||
let fading = with(&[("advanced/visibleCharacters", num(1.5)), ("advanced/fadeTime", num(50.0))], 0.0);
|
||||
let total = |i: &Image| i.data.iter().map(|p| p[3]).sum::<f32>();
|
||||
let (one, both) = (with(&[("advanced/visibleCharacters", num(1.0))], 0.0), with(&[("advanced/visibleCharacters", num(2.0))], 0.0));
|
||||
assert!(total(&fading) > total(&one) + 1.0 && total(&fading) < total(&both) - 1.0, "{} {} {}", total(&one), total(&fading), total(&both));
|
||||
// Jitter differs between frames but is repeatable.
|
||||
let j = [("advanced/jitterSettings/baselineJitterMax", num(10.0))];
|
||||
assert_ne!(with(&j, 0.0), with(&j, 0.5));
|
||||
assert_eq!(with(&j, 0.5), with(&j, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_deterministic_and_animates() {
|
||||
let img = Image::new(96, 64);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
//! masks and ignore effects applied before them (precompose to include those). Without a host
|
||||
//! (tests, thumbnails) they pass their input through.
|
||||
//!
|
||||
//! Pixel-motion based methods (Timewarp's Pixel Motion, Pixel Motion Blur) are approximated by
|
||||
//! frame mixing / sub-frame sampling; there is no optical-flow estimation yet.
|
||||
//! Timewarp's Pixel Motion estimates block-matching optical flow between neighbouring frames
|
||||
//! ([`effectcraft_raster::flow`]); Pixel Motion Blur averages sub-frame samples.
|
||||
//!
|
||||
//! [`EffectHost::self_at`]: crate::EffectHost::self_at
|
||||
|
||||
|
|
@ -16,6 +16,8 @@ use effectcraft_project::ParamUi;
|
|||
use effectcraft_raster::{Image, Px};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use effectcraft_raster::flow::Flow;
|
||||
|
||||
use crate::util::{fit_layer, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider};
|
||||
|
||||
|
|
@ -90,11 +92,29 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("speed", "Speed", num(50.0), slider(-10000.0, 10000.0, 0.0, 200.0, 1)),
|
||||
p("sourceFrame", "Source Frame", num(0.0), slider(-100000.0, 100000.0, 0.0, 300.0, 1)),
|
||||
p("tuning/vectorDetail", "Vector Detail", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("tuning/smoothing/globalSmoothness", "Global Smoothness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("tuning/smoothing/localSmoothness", "Local Smoothness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("tuning/smoothing/smoothingIterations", "Smoothing Iterations", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 0)),
|
||||
p("tuning/buildFromOneImage", "Build From One Image", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("tuning/correctLuminanceChanges", "Correct Luminance Changes", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("tuning/filtering", "Filtering", Value::Enum(0), popup(&["Normal", "Extreme"])),
|
||||
p("tuning/errorThreshold", "Error Threshold", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("tuning/blockSize", "Block Size", num(8.0), slider(4.0, 64.0, 4.0, 32.0, 0)),
|
||||
p("tuning/weighting/redWeighting", "Red Weighting", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("tuning/weighting/greenWeighting", "Green Weighting", num(0.59), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("tuning/weighting/blueWeighting", "Blue Weighting", num(0.11), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("motionBlur/enableMotionBlur", "Enable Motion Blur", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("motionBlur/shutterControl", "Shutter Control", Value::Enum(0), popup(&["Automatic", "Manual"])),
|
||||
p("motionBlur/shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)),
|
||||
p("motionBlur/shutterSamples", "Shutter Samples", num(5.0), slider(1.0, 64.0, 1.0, 32.0, 0)),
|
||||
p("matteLayer", "Matte Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("matteChannel", "Matte Channel", Value::Enum(0), popup(&TW_MATTE_CHANNELS)),
|
||||
p("warpLayer", "Warp Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("show", "Show", Value::Enum(0), popup(&TW_SHOW)),
|
||||
p("sourceCrops/leftCrop", "Left Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)),
|
||||
p("sourceCrops/rightCrop", "Right Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)),
|
||||
p("sourceCrops/bottomCrop", "Bottom Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)),
|
||||
p("sourceCrops/topCrop", "Top Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)),
|
||||
],
|
||||
timewarp,
|
||||
),
|
||||
|
|
@ -428,6 +448,238 @@ pub fn timewarp_source_time(t: f64, by_frame: bool, speed: f64, source_frame: f6
|
|||
if by_frame { source_frame / fps } else { t * speed / 100.0 }
|
||||
}
|
||||
|
||||
/// Timewarp's Pixel Motion settings (the Tuning group and Source Crops).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PixelMotion {
|
||||
/// Vector spacing in pixels (from Vector Detail).
|
||||
pub tile: u32,
|
||||
/// Matching block size in pixels.
|
||||
pub window: u32,
|
||||
pub global_smoothness: f32,
|
||||
pub local_smoothness: f32,
|
||||
pub iterations: u32,
|
||||
pub build_from_one: bool,
|
||||
pub correct_luminance: bool,
|
||||
pub extreme: bool,
|
||||
/// Error Threshold (0..1): pixels whose two warped frames disagree by more fall back to a
|
||||
/// frame mix.
|
||||
pub error_threshold: f32,
|
||||
pub weights: [f32; 3],
|
||||
/// Left, right, top, bottom crops in pixels.
|
||||
pub crops: [u32; 4],
|
||||
}
|
||||
|
||||
impl PixelMotion {
|
||||
fn from(ctx: &EffectCtx, scale: f64) -> PixelMotion {
|
||||
let pr = ctx.params;
|
||||
let g = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d);
|
||||
let detail = g("tuning/vectorDetail", 20.0).clamp(0.0, 100.0) / 100.0;
|
||||
// Vector Detail 0 → one vector per 32 px, 100 → one per 2 px (exponential).
|
||||
let tile = (32.0 * (1.0f64 / 16.0).powf(detail) * scale).round().max(2.0) as u32;
|
||||
let px = |id: &str| (g(id, 0.0).max(0.0) * scale).round() as u32;
|
||||
PixelMotion {
|
||||
tile,
|
||||
window: ((g("tuning/blockSize", 8.0).max(4.0)) * scale).round().max(4.0) as u32,
|
||||
global_smoothness: g("tuning/smoothing/globalSmoothness", 0.5).clamp(0.0, 1.0) as f32,
|
||||
local_smoothness: g("tuning/smoothing/localSmoothness", 0.5).clamp(0.0, 1.0) as f32,
|
||||
iterations: g("tuning/smoothing/smoothingIterations", 20.0).clamp(0.0, 200.0) as u32,
|
||||
build_from_one: pr.b("tuning/buildFromOneImage"),
|
||||
correct_luminance: pr.b("tuning/correctLuminanceChanges"),
|
||||
extreme: pr.e("tuning/filtering") == 1,
|
||||
error_threshold: (g("tuning/errorThreshold", 10.0) / 100.0).clamp(0.0, 1.0) as f32,
|
||||
weights: [g("tuning/weighting/redWeighting", 0.3), g("tuning/weighting/greenWeighting", 0.59), g("tuning/weighting/blueWeighting", 0.11)]
|
||||
.map(|v| v.max(0.0) as f32),
|
||||
crops: [px("sourceCrops/leftCrop"), px("sourceCrops/rightCrop"), px("sourceCrops/topCrop"), px("sourceCrops/bottomCrop")],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the cropped edges of `img` with the nearest kept pixel (Source Crops: edge garbage
|
||||
/// must not drive or smear the motion).
|
||||
fn crop_edges(img: &Image, [l, r, t, b]: [u32; 4]) -> Image {
|
||||
if l == 0 && r == 0 && t == 0 && b == 0 {
|
||||
return img.clone();
|
||||
}
|
||||
let (w, h) = (img.width as i64, img.height as i64);
|
||||
let (x0, x1) = ((l as i64).min(w - 1), (w - 1 - r as i64).max(0));
|
||||
let (y0, y1) = ((t as i64).min(h - 1), (h - 1 - b as i64).max(0));
|
||||
let mut out = img.clone();
|
||||
out.rows_mut().for_each(|(y, row)| {
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
*px = img.get((x as i64).clamp(x0, x1.max(x0)), (y as i64).clamp(y0, y1.max(y0)));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Weighted grey (R·wr + G·wg + B·wb in every colour channel) that the motion is estimated on.
|
||||
fn motion_grey(img: &Image, wt: [f32; 3], gain: f32) -> Image {
|
||||
let sum = (wt[0] + wt[1] + wt[2]).max(1e-6);
|
||||
let mut out = img.clone();
|
||||
out.data.par_iter_mut().for_each(|p| {
|
||||
let g = (p[0] * wt[0] + p[1] * wt[1] + p[2] * wt[2]) / sum * gain;
|
||||
*p = [g, g, g, p[3]];
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn mean_grey(img: &Image) -> f32 {
|
||||
let n = img.data.len().max(1) as f32;
|
||||
img.data.par_iter().map(|p| p[0]).sum::<f32>() / n
|
||||
}
|
||||
|
||||
/// Smooth a motion field: `iterations` passes pulling each vector towards its 3×3 mean by
|
||||
/// Local Smoothness, then a blend towards a wide average by Global Smoothness.
|
||||
fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) {
|
||||
let (c, r) = (flow.cols, flow.rows);
|
||||
if c * r <= 1 {
|
||||
return;
|
||||
}
|
||||
let mean = |v: &[[f32; 2]], rad: i64| -> Vec<[f32; 2]> {
|
||||
(0..c * r)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % c) as i64, (i / c) as i64);
|
||||
let mut s = [0.0f32; 2];
|
||||
let mut n = 0.0;
|
||||
for dy in -rad..=rad {
|
||||
for dx in -rad..=rad {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx >= 0 && ny >= 0 && (nx as usize) < c && (ny as usize) < r {
|
||||
let q = v[ny as usize * c + nx as usize];
|
||||
s[0] += q[0];
|
||||
s[1] += q[1];
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
[s[0] / n, s[1] / n]
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if pm.local_smoothness > 0.0 {
|
||||
for _ in 0..pm.iterations {
|
||||
let m = mean(&flow.v, 1);
|
||||
for (v, q) in flow.v.iter_mut().zip(m) {
|
||||
v[0] += (q[0] - v[0]) * pm.local_smoothness * 0.5;
|
||||
v[1] += (q[1] - v[1]) * pm.local_smoothness * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
if pm.global_smoothness > 0.0 {
|
||||
let m = mean(&flow.v, 3);
|
||||
for (v, q) in flow.v.iter_mut().zip(m) {
|
||||
v[0] += (q[0] - v[0]) * pm.global_smoothness * 0.5;
|
||||
v[1] += (q[1] - v[1]) * pm.global_smoothness * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The motion field from frame `a` to frame `b` (or from the warp layer's frames).
|
||||
pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
|
||||
let ga = motion_grey(&crop_edges(a, pm.crops), pm.weights, 1.0);
|
||||
let gain = if pm.correct_luminance {
|
||||
let (ma, mb) = (mean_grey(&ga), mean_grey(&motion_grey(b, pm.weights, 1.0)));
|
||||
if mb > 1e-4 { ma / mb } else { 1.0 }
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let gb = motion_grey(&crop_edges(b, pm.crops), pm.weights, gain);
|
||||
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, 4);
|
||||
smooth_flow(&mut flow, pm);
|
||||
flow
|
||||
}
|
||||
|
||||
/// Pixel Motion: the frame at fraction `w` between `a` and `b` built by warping along `flow`
|
||||
/// (Build From One Image: from the nearer frame only), falling back to a mix where the two
|
||||
/// warped frames disagree by more than the Error Threshold.
|
||||
pub fn timewarp_interpolate(a: &Image, b: &Image, w: f32, flow: &Flow, pm: &PixelMotion) -> Image {
|
||||
let (a, b) = (crop_edges(a, pm.crops), crop_edges(b, pm.crops));
|
||||
let mut out = Image::new(a.width, a.height);
|
||||
let wd = w as f64;
|
||||
let sample = |img: &Image, x: f64, y: f64| if pm.extreme { img.sample_bicubic(x, y) } else { img.sample_bilinear_clamped(x, y) };
|
||||
out.rows_mut().for_each(|(y, row)| {
|
||||
let cy = y as f64 + 0.5;
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let cx = x as f64 + 0.5;
|
||||
let f = flow.at(cx, cy);
|
||||
let (fx, fy) = (f[0] as f64, f[1] as f64);
|
||||
let pa = sample(&a, cx - wd * fx, cy - wd * fy);
|
||||
let pb = sample(&b, cx + (1.0 - wd) * fx, cy + (1.0 - wd) * fy);
|
||||
if pm.build_from_one {
|
||||
*px = if w < 0.5 { pa } else { pb };
|
||||
continue;
|
||||
}
|
||||
let err = (0..4).map(|c| (pa[c] - pb[c]).abs()).fold(0.0f32, f32::max);
|
||||
let (pa, pb) = if pm.error_threshold > 0.0 && err > pm.error_threshold && pm.error_threshold < 1.0 {
|
||||
(a.get(x as i64, y as i64), b.get(x as i64, y as i64))
|
||||
} else {
|
||||
(pa, pb)
|
||||
};
|
||||
for c in 0..4 {
|
||||
px[c] = pa[c] + (pb[c] - pa[c]) * w;
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Timewarp's Matte Channel options.
|
||||
const TW_MATTE_CHANNELS: [&str; 4] = ["Alpha", "Inverted Alpha", "Luminance", "Inverted Luminance"];
|
||||
/// Timewarp's Show options.
|
||||
const TW_SHOW: [&str; 4] = ["Normal", "Foreground Only", "Background Only", "Matte"];
|
||||
|
||||
/// A matte plane (foreground = 1) from a matte layer frame.
|
||||
fn matte_of(img: &Image, channel: u32) -> Vec<f32> {
|
||||
img.data
|
||||
.par_iter()
|
||||
.map(|p| {
|
||||
let (c, a) = unpremul(*p);
|
||||
let l = (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) * a;
|
||||
match channel {
|
||||
1 => 1.0 - a,
|
||||
2 => l,
|
||||
3 => 1.0 - l,
|
||||
_ => a,
|
||||
}
|
||||
.clamp(0.0, 1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn times_mask(img: &Image, m: &[f32], invert: bool) -> Image {
|
||||
let mut out = img.clone();
|
||||
out.data.par_iter_mut().zip(m.par_iter()).for_each(|(p, &k)| {
|
||||
let k = if invert { 1.0 - k } else { k };
|
||||
p.iter_mut().for_each(|v| *v *= k);
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Another layer (layer parameter `id`) at the comp times matching source layer times `times`,
|
||||
/// fitted onto `grid`. `None` when no layer is chosen.
|
||||
fn fetch_layer(ctx: &EffectCtx, id: &str, times: &[f64], grid: &Buf) -> Option<Vec<Image>> {
|
||||
let lid = ctx.params.get(id)?.as_layer()?;
|
||||
let host = ctx.env.host?;
|
||||
let me = ctx.params.get(&crate::layer_source_id(id)).is_none_or(|v| v.as_enum() != 0);
|
||||
let shift = ctx.env.comp_time - ctx.time;
|
||||
Some(
|
||||
times
|
||||
.iter()
|
||||
.map(|t| match host.layer_at(lid, t + shift, me) {
|
||||
Some(lp) => fit_layer(ctx, grid, &lp, false),
|
||||
None => Image::new(grid.img.width, grid.img.height),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Timewarp. Whole Frames shows the nearest source frame, Frame Mix cross-fades the two
|
||||
/// neighbouring frames and Pixel Motion builds the in-between frame along estimated motion
|
||||
/// vectors (block-matching optical flow, [`effectcraft_raster::flow`]) tuned by the Tuning
|
||||
/// group. A Matte Layer splits foreground and background so each moves on its own vectors; a
|
||||
/// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter
|
||||
/// Samples instants across the shutter.
|
||||
fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
if ctx.env.host.is_none() {
|
||||
return b;
|
||||
|
|
@ -437,36 +689,126 @@ fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf {
|
|||
let speed = ctx.params.f("speed");
|
||||
let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps);
|
||||
let method = ctx.params.e("method");
|
||||
// Build From One Image: interpolated frames come from the nearest source frame alone.
|
||||
let one = method > 0 && ctx.params.b("tuning/buildFromOneImage");
|
||||
// Sample times (with weights) for one instant of source time.
|
||||
let instant = |s: f64| -> Vec<(f64, f32)> {
|
||||
let f = s * fps;
|
||||
let f0 = (f + 1e-6).floor();
|
||||
let frac = (f - f0) as f32;
|
||||
if one {
|
||||
return vec![((f + 1e-6).round() / fps, 1.0)];
|
||||
}
|
||||
if method == 0 || frac < 1e-4 { vec![(f0 / fps, 1.0)] } else { vec![(f0 / fps, 1.0 - frac), ((f0 + 1.0) / fps, frac)] }
|
||||
};
|
||||
let mut samples: Vec<(f64, f32)> = Vec::new();
|
||||
// Instants of source time (with weights) across the shutter.
|
||||
let mut instants: Vec<(f64, f32)> = vec![];
|
||||
if ctx.params.b("motionBlur/enableMotionBlur") {
|
||||
let angle = if ctx.params.e("motionBlur/shutterControl") == 1 { ctx.params.f("motionBlur/shutterAngle") } else { 180.0 };
|
||||
let n = ctx.params.f("motionBlur/shutterSamples").round().clamp(1.0, 64.0) as usize;
|
||||
let rate = if by_frame { 1.0 } else { speed / 100.0 };
|
||||
let span = angle / 360.0 * rate / fps;
|
||||
for i in 0..n {
|
||||
let s = src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 };
|
||||
samples.extend(instant(s).into_iter().map(|(t, w)| (t, w / n as f32)));
|
||||
instants.push((src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 }, 1.0 / n as f32));
|
||||
}
|
||||
} else {
|
||||
samples = instant(src);
|
||||
instants.push((src, 1.0));
|
||||
}
|
||||
// Each instant needs the frame at or before it and the next one.
|
||||
let split = |s: f64| {
|
||||
let f = s * fps;
|
||||
let f0 = (f + 1e-6).floor();
|
||||
(f0, ((f - f0) as f32).max(0.0))
|
||||
};
|
||||
let mut times: Vec<f64> = vec![];
|
||||
for &(s, _) in &instants {
|
||||
let (f0, _) = split(s);
|
||||
times.push(f0 / fps);
|
||||
times.push((f0 + 1.0) / fps);
|
||||
}
|
||||
let times: Vec<f64> = samples.iter().map(|s| s.0).collect();
|
||||
let weights: Vec<f32> = samples.iter().map(|s| s.1).collect();
|
||||
let Some(fr) = fetch(ctx, ×) else { return b };
|
||||
let img = average(&fr.imgs, &weights);
|
||||
with_img(fr.grid, img)
|
||||
let grid = fr.grid.clone();
|
||||
let frame = |t: f64| -> &Image {
|
||||
let k = times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
|
||||
&fr.imgs[k]
|
||||
};
|
||||
let pm = PixelMotion::from(ctx, grid.scale);
|
||||
let mattes = fetch_layer(ctx, "matteLayer", ×, &grid);
|
||||
let warps = fetch_layer(ctx, "warpLayer", ×, &grid);
|
||||
let matte_ch = ctx.params.e("matteChannel");
|
||||
let show = ctx.params.e("show");
|
||||
let mut flows: Vec<((i64, bool), Flow)> = vec![];
|
||||
let mut out_frames = vec![];
|
||||
for &(s, wgt) in &instants {
|
||||
let (f0, frac) = split(s);
|
||||
let (t0, t1) = (f0 / fps, (f0 + 1.0) / fps);
|
||||
let (a, bb) = (frame(t0), frame(t1));
|
||||
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
|
||||
let ma = mattes.as_ref().map(|m| matte_of(&m[idx(t0)], matte_ch));
|
||||
let mb = mattes.as_ref().map(|m| matte_of(&m[idx(t1)], matte_ch));
|
||||
let img = if method == 0 || frac < 1e-4 || (one && method == 1) {
|
||||
// Whole frames (Build From One Image with Frame Mix: the nearest frame).
|
||||
let pick = if method == 0 || frac < 1e-4 { 0.0 } else { frac.round() };
|
||||
let base = if pick < 0.5 { a } else { bb };
|
||||
match (show, &ma, &mb) {
|
||||
(1, Some(m), _) if pick < 0.5 => times_mask(base, m, false),
|
||||
(2, Some(m), _) if pick < 0.5 => times_mask(base, m, true),
|
||||
(1, _, Some(m)) => times_mask(base, m, false),
|
||||
(2, _, Some(m)) => times_mask(base, m, true),
|
||||
(3, Some(m), _) => matte_image(if pick < 0.5 { m } else { mb.as_ref().unwrap_or(m) }, base),
|
||||
_ => base.clone(),
|
||||
}
|
||||
} else if method == 1 {
|
||||
match (&ma, &mb, show) {
|
||||
(Some(m0), Some(m1), 3) => effectcraft_raster::flow::mix(&matte_image(m0, a), &matte_image(m1, bb), frac),
|
||||
(Some(m0), Some(m1), 1 | 2) => effectcraft_raster::flow::mix(×_mask(a, m0, show == 2), ×_mask(bb, m1, show == 2), frac),
|
||||
_ => effectcraft_raster::flow::mix(a, bb, frac),
|
||||
}
|
||||
} else {
|
||||
// Pixel Motion: vectors from the warp layer if any, else from the layer.
|
||||
let key = ((f0 as i64), false);
|
||||
let flow_ab = |fa: &Image, fb: &Image| -> Flow { timewarp_flow(fa, fb, &pm) };
|
||||
let flow = match flows.iter().find(|(k, _)| *k == key) {
|
||||
Some((_, f)) => f.clone(),
|
||||
None => {
|
||||
let f = match &warps {
|
||||
Some(w) => flow_ab(&w[idx(t0)], &w[idx(t1)]),
|
||||
None => flow_ab(a, bb),
|
||||
};
|
||||
flows.push((key, f.clone()));
|
||||
f
|
||||
}
|
||||
};
|
||||
match (&ma, &mb) {
|
||||
(Some(m0), Some(m1)) => {
|
||||
// Foreground and background each move on their own vectors.
|
||||
let (fa, fb) = (times_mask(a, m0, false), times_mask(bb, m1, false));
|
||||
let (ga, gb) = (times_mask(a, m0, true), times_mask(bb, m1, true));
|
||||
let fg_flow = if warps.is_some() { flow.clone() } else { flow_ab(&fa, &fb) };
|
||||
let bg_flow = if warps.is_some() { flow.clone() } else { flow_ab(&ga, &gb) };
|
||||
let fg = timewarp_interpolate(&fa, &fb, frac, &fg_flow, &pm);
|
||||
let bg = timewarp_interpolate(&ga, &gb, frac, &bg_flow, &pm);
|
||||
match show {
|
||||
1 => fg,
|
||||
2 => bg,
|
||||
3 => matte_image(&fg.data.iter().map(|p| p[3]).collect::<Vec<_>>(), &fg),
|
||||
_ => {
|
||||
let mut o = bg;
|
||||
o.data.par_iter_mut().zip(fg.data.par_iter()).for_each(|(p, q)| {
|
||||
let k = 1.0 - q[3];
|
||||
for c in 0..4 {
|
||||
p[c] = q[c] + p[c] * k;
|
||||
}
|
||||
});
|
||||
o
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => timewarp_interpolate(a, bb, frac, &flow, &pm),
|
||||
}
|
||||
};
|
||||
out_frames.push((img, wgt));
|
||||
}
|
||||
let imgs: Vec<Image> = out_frames.iter().map(|(i, _)| i.clone()).collect();
|
||||
let weights: Vec<f32> = out_frames.iter().map(|(_, w)| *w).collect();
|
||||
let img = if imgs.len() == 1 { imgs.into_iter().next().unwrap_or_default() } else { average(&imgs, &weights) };
|
||||
with_img(grid, img)
|
||||
}
|
||||
|
||||
/// A matte shown as opaque grey.
|
||||
fn matte_image(m: &[f32], like: &Image) -> Image {
|
||||
let mut out = Image::new(like.width, like.height);
|
||||
out.data.par_iter_mut().zip(m.par_iter()).for_each(|(p, &k)| *p = [k, k, k, 1.0]);
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Force Motion Blur / CC Wide Time / Pixel Motion Blur
|
||||
|
|
@ -563,6 +905,97 @@ mod tests {
|
|||
assert!((out.img.data[0][0] - 0.5).abs() < 1e-5, "{}", out.img.data[0][0]);
|
||||
}
|
||||
|
||||
/// Texture `x − dx` (smooth, non-repeating).
|
||||
fn texture(w: u32, h: u32, dx: f64) -> Image {
|
||||
let mut img = Image::new(w, h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (u, v) = (x as f64 - dx, y as f64);
|
||||
let s = (0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos() + 0.1 * (u * 0.031 + v * 0.017).sin()) as f32;
|
||||
img.set(x, y, [s, s * 0.8, 1.0 - s, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
/// A textured layer moving right 3 px per frame (10 fps); layer 7 (a "warp" / matte layer)
|
||||
/// is the same texture standing still, layer 8 a matte that is white on the left half.
|
||||
struct Moving;
|
||||
impl EffectHost for Moving {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
|
||||
Some(Buf { img: texture(96, 64, (t * 10.0).round() * 3.0), offset: [0.0; 2], scale: 1.0 })
|
||||
}
|
||||
fn layer_at(&self, id: u64, _: f64, _: bool) -> Option<LayerPixels> {
|
||||
let img = match id {
|
||||
7 => texture(96, 64, 0.0),
|
||||
_ => {
|
||||
let mut m = Image::new(96, 64);
|
||||
for y in 0..64 {
|
||||
for x in 0..48 {
|
||||
m.set(x, y, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
};
|
||||
Some(LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [96.0, 64.0] })
|
||||
}
|
||||
}
|
||||
|
||||
fn mae(a: &Image, b: &Image, margin: i64) -> f32 {
|
||||
let (w, h) = (a.width as i64, a.height as i64);
|
||||
let mut s = 0.0;
|
||||
let mut n = 0.0;
|
||||
for y in margin..h - margin {
|
||||
for x in margin..w - margin {
|
||||
let (p, q) = (a.get(x, y), b.get(x, y));
|
||||
s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::<f32>();
|
||||
n += 3.0;
|
||||
}
|
||||
}
|
||||
s / n
|
||||
}
|
||||
|
||||
fn run_moving(vals: &[(&str, Value)], t: f64) -> Image {
|
||||
let env = EffectEnv { host: Some(&Moving), frame_rate: 10.0, comp_time: t, ..Default::default() };
|
||||
run_fx("ec.time.timewarp", vals, Image::new(96, 64), t, env).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timewarp_pixel_motion_follows_the_motion() {
|
||||
// Speed 50 % at layer time 1.1 s: source 0.55 s, halfway between frames 5 and 6 (15 and
|
||||
// 18 px), so the true in-between frame is the texture at 16.5 px.
|
||||
let want = texture(96, 64, 16.5);
|
||||
let pm = run_moving(&[("method", Value::Enum(2)), ("tuning/vectorDetail", num(60.0))], 1.1);
|
||||
let mix = run_moving(&[("method", Value::Enum(1))], 1.1);
|
||||
let (e_pm, e_mix) = (mae(&pm, &want, 8), mae(&mix, &want, 8));
|
||||
assert!(e_pm < e_mix * 0.5, "pixel motion {e_pm} vs frame mix {e_mix}");
|
||||
// Deterministic and seek-consistent (same time → same pixels).
|
||||
assert_eq!(pm.data, run_moving(&[("method", Value::Enum(2)), ("tuning/vectorDetail", num(60.0))], 1.1).data);
|
||||
// Build From One Image warps the nearer frame alone: still close to the truth.
|
||||
let one = run_moving(&[("method", Value::Enum(2)), ("tuning/buildFromOneImage", Value::Bool(true)), ("tuning/vectorDetail", num(60.0))], 1.1);
|
||||
assert!(mae(&one, &want, 8) < e_mix * 0.5);
|
||||
// A Warp Layer standing still supplies zero motion: the result is a plain mix.
|
||||
let warped = run_moving(&[("method", Value::Enum(2)), ("warpLayer", Value::Layer(Some(7))), ("tuning/errorThreshold", num(100.0))], 1.1);
|
||||
assert!(mae(&warped, &mix, 0) < 1e-3, "{}", mae(&warped, &mix, 0));
|
||||
// Show Matte displays the matte layer; Foreground Only keeps the matte's left half.
|
||||
let m = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("show", Value::Enum(3))], 1.1);
|
||||
assert!(m.get(10, 10)[0] > 0.99 && m.get(80, 10)[0] < 0.01);
|
||||
let fg = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("show", Value::Enum(1))], 1.1);
|
||||
assert!(fg.get(10, 30)[3] > 0.99 && fg.get(85, 30)[3] < 0.01);
|
||||
let normal = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("tuning/vectorDetail", num(60.0))], 1.1);
|
||||
assert!(mae(&normal, &want, 12) < e_mix, "{} {e_mix}", mae(&normal, &want, 12));
|
||||
// Source Crops: a crop replicates the kept edge into the cropped band.
|
||||
let c = run_moving(&[("method", Value::Enum(2)), ("sourceCrops/leftCrop", num(10.0))], 1.1);
|
||||
assert_eq!(c.get(0, 20), c.get(5, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timewarp_and_wide_time() {
|
||||
let h = Clock { static_layer: false };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Transition effects (batch 2): Block Dissolve, Gradient Wipe (gradient layer or self), Iris Wipe, Card
|
||||
//! Wipe (2D cards), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe.
|
||||
//! Wipe (3D cards with camera, lighting and jitter), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe.
|
||||
//!
|
||||
//! Every transition is the identity at 0 % completion. Angles are clockwise from "up".
|
||||
|
||||
|
|
@ -9,6 +9,7 @@ use effectcraft_project::ParamUi;
|
|||
use effectcraft_raster::Px;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::sim2::{Piece, draw_pieces, rot_axis, sort_far_first};
|
||||
use crate::util::{Plane, gauss_plane, hash1, layer_rect, remap, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, col, num, p, popup, slider};
|
||||
|
||||
|
|
@ -61,14 +62,21 @@ fn block_dissolve(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let feather = ctx.params.f("feather") * b.scale;
|
||||
let (ox, oy) = (b.offset[0], b.offset[1]);
|
||||
let seed = ctx.seed ^ 0xb10c;
|
||||
// Soft Edges (Best Quality): block edges falling between pixels are anti-aliased (4×4
|
||||
// sub-pixel samples); off, each pixel takes its centre's block.
|
||||
let soft = ctx.params.b("softEdges");
|
||||
let subs: Vec<f64> = if soft { (0..4).map(|k| (k as f64 + 0.5) / 4.0).collect() } else { vec![0.5] };
|
||||
let kept = |x: f64, y: f64| {
|
||||
let (bx, by) = (((x - ox) / bw).floor() as i64, ((y - oy) / bh).floor() as i64);
|
||||
hash1(bx as u32, by as u32, seed) >= done
|
||||
};
|
||||
let mut mask = Plane::new(b.img.width as usize, b.img.height as usize);
|
||||
let w = mask.w;
|
||||
let n = (subs.len() * subs.len()) as f32;
|
||||
mask.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
|
||||
let by = ((y as f64 + 0.5 - oy) / bh).floor() as i64;
|
||||
for (x, m) in row.iter_mut().enumerate() {
|
||||
let bx = ((x as f64 + 0.5 - ox) / bw).floor() as i64;
|
||||
let r = hash1(bx as u32, by as u32, seed);
|
||||
*m = if r >= done { 1.0 } else { 0.0 };
|
||||
let hits = subs.iter().flat_map(|sy| subs.iter().map(move |sx| (*sx, *sy))).filter(|(sx, sy)| kept(x as f64 + sx, y as f64 + sy)).count();
|
||||
*m = hits as f32 / n;
|
||||
}
|
||||
});
|
||||
if feather > 0.0 {
|
||||
|
|
@ -163,21 +171,47 @@ fn iris_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
b
|
||||
}
|
||||
|
||||
/// Card Wipe: the layer is cut into Rows × Columns cards that flip over in Flip Order, each
|
||||
/// through a 180° turn about Flip Axis, as textured planes in 3D seen through the Camera
|
||||
/// System and lit by Lighting / Material. The flipped side shows Back Layer (none: the cards
|
||||
/// vanish as they turn away). Position / Rotation Jitter wobble the cards over time.
|
||||
fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let done = completion(ctx);
|
||||
if done <= 0.0 {
|
||||
return b;
|
||||
let pr = ctx.params;
|
||||
let f = |id: &str| pr.get(id).map(Value::as_f64).unwrap_or(0.0);
|
||||
let jitter_on = [
|
||||
"positionJitter/xJitterAmount",
|
||||
"positionJitter/yJitterAmount",
|
||||
"positionJitter/zJitterAmount",
|
||||
"rotationJitter/xRotJitterAmount",
|
||||
"rotationJitter/yRotJitterAmount",
|
||||
"rotationJitter/zRotJitterAmount",
|
||||
]
|
||||
.iter()
|
||||
.any(|id| f(id) != 0.0);
|
||||
if done <= 0.0
|
||||
&& !jitter_on
|
||||
&& pr.e("cameraSystem") == 0
|
||||
&& f("cameraPosition/xRotation") == 0.0
|
||||
&& f("cameraPosition/yRotation") == 0.0
|
||||
&& f("cameraPosition/zRotation") == 0.0
|
||||
{
|
||||
// Nothing has moved: the layer as is (exactly).
|
||||
let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2());
|
||||
if xy.is_none_or(|p| (p[0] - ctx.layer_size[0] * 0.5).abs() < 1e-9 && (p[1] - ctx.layer_size[1] * 0.5).abs() < 1e-9) {
|
||||
return b;
|
||||
}
|
||||
}
|
||||
let tw = (ctx.params.f("transitionWidth") / 100.0).clamp(0.01, 1.0);
|
||||
let rows = ctx.params.f("rows").round().clamp(1.0, 1000.0) as i64;
|
||||
let tw = (pr.f("transitionWidth") / 100.0).clamp(0.01, 1.0);
|
||||
let rows = pr.f("rows").round().clamp(1.0, 1000.0) as i64;
|
||||
// Rows & Columns: Independent, or Columns Follows Rows.
|
||||
let cols = if ctx.params.e("rowsAndColumns") == 1 { rows } else { ctx.params.f("columns").round().clamp(1.0, 1000.0) as i64 };
|
||||
let card_scale = ctx.params.f("cardScale").max(0.01);
|
||||
let axis = ctx.params.e("flipAxis");
|
||||
let order = ctx.params.e("flipOrder");
|
||||
let back_self = ctx.params.e("backLayer") == 1;
|
||||
let jitter = ctx.params.f("timingRandomness").clamp(0.0, 1.0);
|
||||
let seed = ctx.params.f("randomSeed") as u32 ^ 0xca7d;
|
||||
let cols = if pr.e("rowsAndColumns") == 1 { rows } else { pr.f("columns").round().clamp(1.0, 1000.0) as i64 };
|
||||
let card_scale = pr.f("cardScale").max(0.01);
|
||||
let axis = pr.e("flipAxis");
|
||||
let direction = pr.e("flipDirection");
|
||||
let order = pr.e("flipOrder");
|
||||
let jitter = pr.f("timingRandomness").clamp(0.0, 1.0);
|
||||
let seed = pr.f("randomSeed") as u32 ^ 0xca7d;
|
||||
let (x0, y0, w, h) = layer_rect(ctx, &b);
|
||||
let (cw, ch) = (w / cols as f64, h / rows as f64);
|
||||
let nx = |i: i64| if cols > 1 { i as f64 / (cols - 1) as f64 } else { 0.0 };
|
||||
|
|
@ -187,64 +221,91 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
Some(o) => place_layer(ctx, &b, &o, 2),
|
||||
None => b.img.clone(),
|
||||
});
|
||||
let src = b.img.clone();
|
||||
b.img = remap(&src, false, |x, y| {
|
||||
let (lx, ly) = (x - x0, y - y0);
|
||||
if lx < 0.0 || ly < 0.0 || lx >= w || ly >= h {
|
||||
return Some((x, y));
|
||||
}
|
||||
let i = ((lx / cw).floor() as i64).min(cols - 1);
|
||||
let j = ((ly / ch).floor() as i64).min(rows - 1);
|
||||
let (ccx, ccy) = (x0 + (i as f64 + 0.5) * cw, y0 + (j as f64 + 0.5) * ch);
|
||||
let mut o = match order {
|
||||
1 => 1.0 - nx(i),
|
||||
2 => ny(j),
|
||||
3 => 1.0 - ny(j),
|
||||
4 => (nx(i) + ny(j)) * 0.5,
|
||||
5 => (1.0 - nx(i) + ny(j)) * 0.5,
|
||||
6 => (nx(i) + 1.0 - ny(j)) * 0.5,
|
||||
7 => 1.0 - (nx(i) + ny(j)) * 0.5,
|
||||
8 => {
|
||||
let g = grad.as_ref().map(|g| g.get_clamped(ccx as i64, ccy as i64)).unwrap_or([0.0; 4]);
|
||||
let (c, _) = unpremul(g);
|
||||
luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64
|
||||
// Back Layer: a chosen layer (stretched to fit), or this layer for projects saved with the
|
||||
// old "Self" choice; none hides the cards' backs.
|
||||
let back = match ctx.layer_param("backLayer", true) {
|
||||
Some(o) => Some(crate::util::fit_layer(ctx, &b, &o, true)),
|
||||
None if pr.b("backSelf") => Some(b.img.clone()),
|
||||
None => None,
|
||||
};
|
||||
let cam = crate::card3d::projection(ctx, &b);
|
||||
let eye = cam.eye();
|
||||
let light = crate::card3d::Lighting::from(ctx, &b);
|
||||
let t = ctx.time;
|
||||
let jit = |i: i64, j: i64, k: u32, speed: f64| -> f64 {
|
||||
let ph = hash1(i as u32, j as u32, seed ^ k) as f64 * std::f64::consts::TAU;
|
||||
let rate = 0.5 + hash1(i as u32, j as u32, seed ^ (k + 101)) as f64;
|
||||
(t * speed * rate * std::f64::consts::TAU + ph).sin()
|
||||
};
|
||||
let (jx, jy, jz, js) =
|
||||
(f("positionJitter/xJitterAmount"), f("positionJitter/yJitterAmount"), f("positionJitter/zJitterAmount"), f("positionJitter/jitterSpeed"));
|
||||
let (rjx, rjy, rjz, rjs) =
|
||||
(f("rotationJitter/xRotJitterAmount"), f("rotationJitter/yRotJitterAmount"), f("rotationJitter/zRotJitterAmount"), f("rotationJitter/rotJitterSpeed"));
|
||||
let mut pieces: Vec<Piece> = (0..rows * cols)
|
||||
.into_par_iter()
|
||||
.filter_map(|k| {
|
||||
let (i, j) = (k % cols, k / cols);
|
||||
let (ccx, ccy) = (x0 + (i as f64 + 0.5) * cw, y0 + (j as f64 + 0.5) * ch);
|
||||
let mut o = match order {
|
||||
1 => 1.0 - nx(i),
|
||||
2 => ny(j),
|
||||
3 => 1.0 - ny(j),
|
||||
4 => (nx(i) + ny(j)) * 0.5,
|
||||
5 => (1.0 - nx(i) + ny(j)) * 0.5,
|
||||
6 => (nx(i) + 1.0 - ny(j)) * 0.5,
|
||||
7 => 1.0 - (nx(i) + ny(j)) * 0.5,
|
||||
8 => {
|
||||
let g = grad.as_ref().map(|g| g.get_clamped(ccx as i64, ccy as i64)).unwrap_or([0.0; 4]);
|
||||
let (c, _) = unpremul(g);
|
||||
luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64
|
||||
}
|
||||
_ => nx(i),
|
||||
};
|
||||
if jitter > 0.0 {
|
||||
o += (hash1(i as u32, j as u32, seed ^ 0x7177) as f64 - o) * jitter;
|
||||
}
|
||||
_ => nx(i),
|
||||
};
|
||||
if jitter > 0.0 {
|
||||
o += (hash1(i as u32, j as u32, seed ^ 0x7177) as f64 - o) * jitter;
|
||||
}
|
||||
let p = ((done - o * (1.0 - tw)) / tw).clamp(0.0, 1.0);
|
||||
// Card Scale: each card is scaled about its centre (gaps below 1, cropped above 1).
|
||||
let (mut dx, mut dy) = ((x - ccx) / card_scale, (y - ccy) / card_scale);
|
||||
if dx.abs() > cw * 0.5 + 1e-9 || dy.abs() > ch * 0.5 + 1e-9 {
|
||||
return None;
|
||||
}
|
||||
if p <= 0.0 {
|
||||
return Some((ccx + dx, ccy + dy));
|
||||
}
|
||||
let cos = (p * std::f64::consts::PI).cos();
|
||||
if cos.abs() < 1e-4 || (cos < 0.0 && !back_self) {
|
||||
return None;
|
||||
}
|
||||
let around_x = match axis {
|
||||
1 => false,
|
||||
2 => hash1(i as u32, j as u32, seed ^ 0x55) < 0.5,
|
||||
_ => true,
|
||||
};
|
||||
if around_x {
|
||||
dy /= cos;
|
||||
if dy.abs() > ch * 0.5 {
|
||||
let p = ((done - o * (1.0 - tw)) / tw).clamp(0.0, 1.0);
|
||||
let around_x = match axis {
|
||||
1 => false,
|
||||
2 => hash1(i as u32, j as u32, seed ^ 0x55) < 0.5,
|
||||
_ => true,
|
||||
};
|
||||
let sign = match direction {
|
||||
1 => -1.0,
|
||||
2 if hash1(i as u32, j as u32, seed ^ 0x99) < 0.5 => -1.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
let a = p * std::f64::consts::PI * sign;
|
||||
let flip = if around_x { rot_axis([1.0, 0.0, 0.0], a) } else { rot_axis([0.0, 1.0, 0.0], a) };
|
||||
let jr = crate::card3d::rot_ordered(
|
||||
(rjx * jit(i, j, 11, rjs)).to_radians(),
|
||||
(rjy * jit(i, j, 12, rjs)).to_radians(),
|
||||
(rjz * jit(i, j, 13, rjs)).to_radians(),
|
||||
0,
|
||||
);
|
||||
let r = crate::card3d::mat_mul(&jr, &flip);
|
||||
let pos = [ccx + jx * jit(i, j, 21, js) * cw, ccy + jy * jit(i, j, 22, js) * ch, jz * jit(i, j, 23, js) * cw.max(ch)];
|
||||
// Card Scale: each card is scaled about its centre (gaps below 1, cropped above 1).
|
||||
let (sw, sh) = ((cw * 0.5 * card_scale.min(1.0)) as f32, (ch * 0.5 * card_scale.min(1.0)) as f32);
|
||||
let (cx, cy) = (ccx as f32, ccy as f32);
|
||||
let poly = [[cx - sw, cy - sh], [cx + sw, cy - sh], [cx + sw, cy + sh], [cx - sw, cy + sh]];
|
||||
// Above 1 the card's picture is magnified (cropped to the card).
|
||||
let k = card_scale.max(1.0);
|
||||
let rs: crate::card3d::M3 = std::array::from_fn(|q| [r[q][0] * k, r[q][1] * k, r[q][2]]);
|
||||
let poly = poly.map(|v| [cx + (v[0] - cx) / k as f32, cy + (v[1] - cy) / k as f32]);
|
||||
let mut pc = Piece::new(&poly, [cx, cy], &rs, pos, cam)?;
|
||||
if pc.back && back.is_none() {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
dx /= cos;
|
||||
if dx.abs() > cw * 0.5 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((ccx + dx, ccy + dy))
|
||||
});
|
||||
pc.back_mirror = if around_x { 1 } else { 2 };
|
||||
pc.light(&light, &r, pos, eye);
|
||||
Some(pc)
|
||||
})
|
||||
.collect();
|
||||
sort_far_first(&mut pieces);
|
||||
let mut out = effectcraft_raster::Image::new(b.img.width, b.img.height);
|
||||
draw_pieces(&mut out, &pieces, &b.img, back.as_ref());
|
||||
b.img = out;
|
||||
b
|
||||
}
|
||||
|
||||
|
|
@ -412,6 +473,7 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("blockWidth", "Block Width", num(1.0), slider(1.0, 4000.0, 1.0, 100.0, 1)),
|
||||
p("blockHeight", "Block Height", num(1.0), slider(1.0, 4000.0, 1.0, 100.0, 1)),
|
||||
p("feather", "Feather", num(0.0), slider(0.0, 400.0, 0.0, 50.0, 1)),
|
||||
p("softEdges", "Soft Edges (Best Quality)", Value::Bool(true), ParamUi::Checkbox),
|
||||
],
|
||||
block_dissolve,
|
||||
),
|
||||
|
|
@ -447,12 +509,15 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
vec![
|
||||
done(),
|
||||
p("transitionWidth", "Transition Width", num(50.0), slider(1.0, 100.0, 1.0, 100.0, 0)),
|
||||
p("backLayer", "Back Layer", Value::Enum(0), popup(&["None", "Self"])),
|
||||
p("backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer),
|
||||
// Projects saved when Back Layer was a None / Self popup: Self.
|
||||
p("backSelf", "Back Layer Is Self", Value::Bool(false), ParamUi::Hidden),
|
||||
p("rowsAndColumns", "Rows & Columns", Value::Enum(0), popup(&["Independent", "Columns Follows Rows"])),
|
||||
p("rows", "Rows", num(8.0), slider(1.0, 1000.0, 1.0, 100.0, 0)),
|
||||
p("columns", "Columns", num(8.0), slider(1.0, 1000.0, 1.0, 100.0, 0)),
|
||||
p("cardScale", "Card Scale", num(1.0), slider(0.01, 10.0, 0.01, 2.0, 2)),
|
||||
p("flipAxis", "Flip Axis", Value::Enum(0), popup(&["X", "Y", "Random"])),
|
||||
p("flipDirection", "Flip Direction", Value::Enum(0), popup(&["Positive", "Negative", "Random"])),
|
||||
p(
|
||||
"flipOrder",
|
||||
"Flip Order",
|
||||
|
|
@ -472,7 +537,22 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer),
|
||||
p("timingRandomness", "Timing Randomness", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)),
|
||||
p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)),
|
||||
],
|
||||
]
|
||||
.into_iter()
|
||||
.chain(crate::card3d::camera_params(2.0))
|
||||
.chain(crate::card3d::lighting_params(1.0, 0.25))
|
||||
.chain(crate::card3d::material_params(0.75))
|
||||
.chain([
|
||||
p("positionJitter/xJitterAmount", "X Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
|
||||
p("positionJitter/jitterSpeed", "Jitter Speed", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
|
||||
p("positionJitter/yJitterAmount", "Y Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
|
||||
p("positionJitter/zJitterAmount", "Z Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)),
|
||||
p("rotationJitter/xRotJitterAmount", "X Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)),
|
||||
p("rotationJitter/rotJitterSpeed", "Rot Jitter Speed", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
|
||||
p("rotationJitter/yRotJitterAmount", "Y Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)),
|
||||
p("rotationJitter/zRotJitterAmount", "Z Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)),
|
||||
])
|
||||
.collect(),
|
||||
card_wipe,
|
||||
),
|
||||
spec(
|
||||
|
|
@ -635,7 +715,7 @@ mod tests {
|
|||
let back = run(
|
||||
"ec.transition.cardwipe",
|
||||
&img,
|
||||
&[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0)), ("backLayer", Value::Enum(1))],
|
||||
&[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0)), ("backSelf", Value::Bool(true))],
|
||||
);
|
||||
assert_eq!(back.get(2, 20)[3], 1.0);
|
||||
}
|
||||
|
|
@ -746,4 +826,63 @@ mod tests {
|
|||
assert_eq!(out.get(50, 50)[3], 0.0, "centre tiles are gone first");
|
||||
assert_eq!(out.get(1, 1)[3], 1.0, "corners last");
|
||||
}
|
||||
|
||||
fn run_t(img: &Image, set: &[(&str, Value)], time: f64, host: Option<&dyn crate::EffectHost>) -> Image {
|
||||
let s = find("ec.transition.cardwipe").unwrap();
|
||||
let size = [img.width as f64, img.height as f64];
|
||||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, size))).collect() };
|
||||
for (k, v) in set {
|
||||
params.values.insert(k.to_string(), v.clone());
|
||||
}
|
||||
let env = crate::EffectEnv { host, ..Default::default() };
|
||||
let ctx = EffectCtx { params: ¶ms, time, layer_size: size, seed: 7, adjustment: false, env };
|
||||
(s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_wipe_back_layer_direction_jitter_and_camera() {
|
||||
struct Green;
|
||||
impl crate::EffectHost for Green {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
Some(crate::LayerPixels { buf: Buf { img: Image::filled(80, 40, [0.0, 1.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [80.0, 40.0] })
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let img = Image::filled(80, 40, [1.0, 0.0, 0.0, 1.0]);
|
||||
let v = [("completion", num(100.0)), ("rows", num(1.0)), ("columns", num(8.0))];
|
||||
// Fully flipped cards show the Back Layer.
|
||||
let mut b: Vec<(&str, Value)> = v.to_vec();
|
||||
b.push(("backLayer", Value::Layer(Some(3))));
|
||||
let out = run_t(&img, &b, 0.0, Some(&Green));
|
||||
let p = out.get(45, 20);
|
||||
assert!(p[1] > 0.95 && p[0] < 0.05, "{p:?}");
|
||||
// Half way, Flip Direction decides which way the cards turn (the views differ).
|
||||
let half = [("completion", num(30.0)), ("rows", num(1.0)), ("columns", num(1.0)), ("transitionWidth", num(100.0)), ("flipAxis", Value::Enum(1))];
|
||||
let pos = run_t(&img, &[half.as_slice(), &[("cameraPosition/xRotation", num(20.0))]].concat(), 0.0, None);
|
||||
let neg = run_t(&img, &[half.as_slice(), &[("cameraPosition/xRotation", num(20.0)), ("flipDirection", Value::Enum(1))]].concat(), 0.0, None);
|
||||
assert_ne!(pos, neg);
|
||||
// Position Jitter moves the cards over time (and only then).
|
||||
let j = [("completion", num(0.0)), ("positionJitter/xJitterAmount", num(0.3))];
|
||||
let a = run_t(&img, &j, 0.0, None);
|
||||
let c = run_t(&img, &j, 0.37, None);
|
||||
assert_ne!(a, c);
|
||||
assert_eq!(c, run_t(&img, &j, 0.37, None), "deterministic");
|
||||
assert_eq!(run_t(&img, &[("completion", num(0.0))], 0.37, None), img, "no jitter, no change");
|
||||
// Camera Position rotation tilts the whole wipe in perspective.
|
||||
let tilt = run_t(&img, &[("completion", num(0.0)), ("cameraPosition/yRotation", num(40.0))], 0.0, None);
|
||||
assert_eq!(tilt.get(0, 0)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_dissolve_soft_edges_antialias_blocks() {
|
||||
let img = Image::filled(40, 4, [1.0, 1.0, 1.0, 1.0]);
|
||||
let v = [("completion", num(50.0)), ("blockWidth", num(2.5)), ("blockHeight", num(4.0))];
|
||||
let frac = |o: &Image| o.data.iter().filter(|p| p[3] > 0.01 && p[3] < 0.99).count();
|
||||
let hard = run("ec.transition.blockdissolve", &img, &[v.as_slice(), &[("softEdges", Value::Bool(false))]].concat());
|
||||
let soft = run("ec.transition.blockdissolve", &img, &v);
|
||||
assert_eq!(frac(&hard), 0);
|
||||
assert!(frac(&soft) > 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -563,6 +563,78 @@ impl<S: Clone> SimCache<S> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 1D squared-distance transform of sampled function `f` (Felzenszwalb & Huttenlocher 2012;
|
||||
/// `f` is 0 at sites and +∞ elsewhere).
|
||||
fn sq_dt_1d(f: &[f64], out: &mut [f64]) {
|
||||
let n = f.len();
|
||||
let sites: Vec<usize> = (0..n).filter(|&i| f[i].is_finite()).collect();
|
||||
if sites.is_empty() {
|
||||
out.iter_mut().for_each(|o| *o = f64::INFINITY);
|
||||
return;
|
||||
}
|
||||
let mut v = vec![0usize; sites.len()];
|
||||
let mut z = vec![0f64; sites.len() + 1];
|
||||
let mut k = 0usize;
|
||||
v[0] = sites[0];
|
||||
z[0] = f64::NEG_INFINITY;
|
||||
z[1] = f64::INFINITY;
|
||||
let s_of = |q: usize, p: usize| ((f[q] + (q * q) as f64) - (f[p] + (p * p) as f64)) / (2.0 * q as f64 - 2.0 * p as f64);
|
||||
for &q in &sites[1..] {
|
||||
let mut s = s_of(q, v[k]);
|
||||
while s <= z[k] {
|
||||
k -= 1;
|
||||
s = s_of(q, v[k]);
|
||||
}
|
||||
k += 1;
|
||||
v[k] = q;
|
||||
z[k] = s;
|
||||
z[k + 1] = f64::INFINITY;
|
||||
}
|
||||
k = 0;
|
||||
for (q, o) in out.iter_mut().enumerate() {
|
||||
while z[k + 1] < q as f64 {
|
||||
k += 1;
|
||||
}
|
||||
let d = q as f64 - v[k] as f64;
|
||||
*o = d * d + f[v[k]];
|
||||
}
|
||||
}
|
||||
|
||||
/// Euclidean distance from every pixel to the nearest `true` pixel of `mask` (`w`×`h`),
|
||||
/// infinite when there is none.
|
||||
pub fn distance_transform(mask: &[bool], w: usize, h: usize) -> Vec<f32> {
|
||||
let mut g: Vec<f64> = mask.iter().map(|&m| if m { 0.0 } else { f64::INFINITY }).collect();
|
||||
// Columns, then rows.
|
||||
let cols: Vec<Vec<f64>> = (0..w)
|
||||
.into_par_iter()
|
||||
.map(|x| {
|
||||
let f: Vec<f64> = (0..h).map(|y| g[y * w + x]).collect();
|
||||
let mut o = vec![0.0; h];
|
||||
sq_dt_1d(&f, &mut o);
|
||||
o
|
||||
})
|
||||
.collect();
|
||||
for (x, col) in cols.iter().enumerate() {
|
||||
for y in 0..h {
|
||||
g[y * w + x] = col[y];
|
||||
}
|
||||
}
|
||||
g.par_chunks_mut(w.max(1)).for_each(|row| {
|
||||
let f = row.to_vec();
|
||||
sq_dt_1d(&f, row);
|
||||
});
|
||||
g.iter().map(|&d| d.sqrt() as f32).collect()
|
||||
}
|
||||
|
||||
/// Signed distance to the boundary of the `inside` region: negative inside, positive outside
|
||||
/// (pixel centres; ±0.5 at the boundary).
|
||||
pub fn signed_distance(inside: &[bool], w: usize, h: usize) -> Plane {
|
||||
let outside: Vec<bool> = inside.iter().map(|v| !v).collect();
|
||||
let to_in = distance_transform(inside, w, h);
|
||||
let to_out = distance_transform(&outside, w, h);
|
||||
Plane { w, h, data: to_in.iter().zip(&to_out).map(|(a, b)| if *a > 0.0 { a - 0.5 } else { -(b - 0.5) }).collect() }
|
||||
}
|
||||
|
||||
/// Point-in-polygon (even-odd) for a closed polyline.
|
||||
pub fn point_in_poly(pts: &[[f64; 2]], x: f64, y: f64) -> bool {
|
||||
let n = pts.len();
|
||||
|
|
|
|||
|
|
@ -154,7 +154,20 @@ fn mtv(m: &[[f64; 3]; 3], v: V3) -> V3 {
|
|||
|
||||
// ---------------------------------------------------------------- projections (VR Converter)
|
||||
|
||||
pub const PROJECTIONS: [&str; 6] = ["Equirectangular 2:1", "Cube-map 4:3", "Cube-map Pano2VR 3:2", "Cube-map GearVR 6:1", "Sphere-map", "2D Source"];
|
||||
/// VR Converter's projections / layouts. Cube-map Facebook 3:2 and Cube-map EAC 3:2 put
|
||||
/// left, front, right over bottom, back, top (EAC with equi-angular faces); Fisheye
|
||||
/// (FullDome) is an angular fisheye looking up at the zenith with the front at the bottom.
|
||||
pub const PROJECTIONS: [&str; 9] = [
|
||||
"Equirectangular 2:1",
|
||||
"Cube-map 4:3",
|
||||
"Cube-map Pano2VR 3:2",
|
||||
"Cube-map GearVR 6:1",
|
||||
"Sphere-map",
|
||||
"2D Source",
|
||||
"Cube-map Facebook 3:2",
|
||||
"Cube-map EAC 3:2",
|
||||
"Fisheye (FullDome)",
|
||||
];
|
||||
|
||||
/// Cube faces: (forward, right, up).
|
||||
const FACES: [(V3, V3, V3); 6] = [
|
||||
|
|
@ -173,6 +186,8 @@ fn cube_grid(kind: u32) -> ((u32, u32), [(u32, u32); 6]) {
|
|||
1 => ((4, 3), [(2, 1), (0, 1), (1, 0), (1, 2), (1, 1), (3, 1)]),
|
||||
// 3 × 2: right, left, top / bottom, front, back.
|
||||
2 => ((3, 2), [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]),
|
||||
// 3 × 2: left, front, right / bottom, back, top (Facebook, EAC).
|
||||
6 | 7 => ((3, 2), [(2, 0), (0, 0), (2, 1), (0, 1), (1, 0), (1, 1)]),
|
||||
// 6 × 1 strip in face order.
|
||||
_ => ((6, 1), [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0)]),
|
||||
}
|
||||
|
|
@ -191,16 +206,34 @@ impl Proj {
|
|||
pub fn dir(&self, x: f64, y: f64, w: f64, h: f64) -> Option<V3> {
|
||||
match self.kind {
|
||||
0 => Some(equi_dir(x, y, w, h)),
|
||||
1..=3 => {
|
||||
1..=3 | 6 | 7 => {
|
||||
let ((gw, gh), cells) = cube_grid(self.kind);
|
||||
let (cw, ch) = (w / gw as f64, h / gh as f64);
|
||||
let (cx, cy) = ((x / cw).floor(), (y / ch).floor());
|
||||
let f = cells.iter().position(|&(c, r)| c as f64 == cx && r as f64 == cy)?;
|
||||
let a = (x - cx * cw) / cw * 2.0 - 1.0;
|
||||
let b = (y - cy * ch) / ch * 2.0 - 1.0;
|
||||
let mut a = (x - cx * cw) / cw * 2.0 - 1.0;
|
||||
let mut b = (y - cy * ch) / ch * 2.0 - 1.0;
|
||||
if self.kind == 7 {
|
||||
// Equi-angular faces: positions are linear in angle.
|
||||
a = (a * std::f64::consts::FRAC_PI_4).tan();
|
||||
b = (b * std::f64::consts::FRAC_PI_4).tan();
|
||||
}
|
||||
let (fw, r, u) = FACES[f];
|
||||
Some(norm([fw[0] + a * r[0] - b * u[0], fw[1] + a * r[1] - b * u[1], fw[2] + a * r[2] - b * u[2]]))
|
||||
}
|
||||
8 => {
|
||||
// FullDome: 180° angular fisheye centred on the zenith (+Y), front at the bottom.
|
||||
let s = w.min(h) / 2.0;
|
||||
let (u, v) = ((x - w / 2.0) / s, (y - h / 2.0) / s);
|
||||
let r = (u * u + v * v).sqrt();
|
||||
if r > 1.0 {
|
||||
return None;
|
||||
}
|
||||
let th = r * std::f64::consts::FRAC_PI_2;
|
||||
let (cu, cv) = if r > 1e-12 { (u / r, v / r) } else { (0.0, 0.0) };
|
||||
// Local: forward = +Y, image right = +X, image down = +Z.
|
||||
Some([th.sin() * cu, th.cos(), th.sin() * cv])
|
||||
}
|
||||
4 => {
|
||||
// Angular fisheye centred on +Z; radius ∝ angle, `fov` across the image width.
|
||||
let half = (self.fov.clamp(1.0, 360.0) / 2.0).to_radians();
|
||||
|
|
@ -231,17 +264,32 @@ impl Proj {
|
|||
let (x, y) = equi_px(d, w, h);
|
||||
Some((x, y, None))
|
||||
}
|
||||
1..=3 => {
|
||||
1..=3 | 6 | 7 => {
|
||||
let f = (0..6).max_by(|&i, &j| dot(d, FACES[i].0).total_cmp(&dot(d, FACES[j].0)))?;
|
||||
let (fw, r, u) = FACES[f];
|
||||
let k = dot(d, fw);
|
||||
let (a, b) = (dot(d, r) / k, -dot(d, u) / k);
|
||||
let (mut a, mut b) = (dot(d, r) / k, -dot(d, u) / k);
|
||||
if self.kind == 7 {
|
||||
a = a.atan() / std::f64::consts::FRAC_PI_4;
|
||||
b = b.atan() / std::f64::consts::FRAC_PI_4;
|
||||
}
|
||||
let ((gw, gh), cells) = cube_grid(self.kind);
|
||||
let (cw, ch) = (w / gw as f64, h / gh as f64);
|
||||
let (c, rr) = cells[f];
|
||||
let (x0, y0) = (c as f64 * cw, rr as f64 * ch);
|
||||
Some((x0 + (a + 1.0) * 0.5 * cw, y0 + (b + 1.0) * 0.5 * ch, Some([x0, y0, x0 + cw, y0 + ch])))
|
||||
}
|
||||
8 => {
|
||||
let th = d[1].clamp(-1.0, 1.0).acos();
|
||||
if th > std::f64::consts::FRAC_PI_2 + 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let s = w.min(h) / 2.0;
|
||||
let r = th / std::f64::consts::FRAC_PI_2;
|
||||
let l = (d[0] * d[0] + d[2] * d[2]).sqrt();
|
||||
let (cu, cv) = if l > 1e-12 { (d[0] / l, d[2] / l) } else { (0.0, 0.0) };
|
||||
Some((w / 2.0 + cu * r * s, h / 2.0 + cv * r * s, None))
|
||||
}
|
||||
4 => {
|
||||
let half = (self.fov.clamp(1.0, 360.0) / 2.0).to_radians();
|
||||
let th = d[2].clamp(-1.0, 1.0).acos();
|
||||
|
|
@ -578,7 +626,7 @@ fn vr_converter(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
let from = Proj { kind: ctx.params.e("sourceProjection"), fov: ctx.params.f("sourceHorizontalFov") };
|
||||
let to = Proj { kind: ctx.params.e("targetProjection"), fov: ctx.params.f("targetHorizontalFov") };
|
||||
let (t, pn, r) = (ctx.params.f("tilt"), ctx.params.f("pan"), ctx.params.f("roll"));
|
||||
if from.kind == to.kind && (from.kind <= 3 || from.fov == to.fov) && t == 0.0 && pn == 0.0 && r == 0.0 {
|
||||
if from.kind == to.kind && (!matches!(from.kind, 4 | 5) || from.fov == to.fov) && t == 0.0 && pn == 0.0 && r == 0.0 {
|
||||
return b;
|
||||
}
|
||||
let m = rot_params(ctx);
|
||||
|
|
@ -825,39 +873,86 @@ fn vr_fractal(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
|||
b
|
||||
}
|
||||
|
||||
/// VR Digital Glitch: horizontal bands of the panorama jump sideways (Geometric Distortion,
|
||||
/// split into Horizontal / Vertical displacement), their colour channels split apart (Color
|
||||
/// Distortion with per-channel offsets) and scan lines darken alternate rows (Scanline
|
||||
/// Spacing apart). The pattern re-randomises Distortion Rate times a second, and Distortion
|
||||
/// Evolution (optionally cycling) moves it smoothly in between. Target Point limits the glitch
|
||||
/// to a region of the sphere (Radius, Feather; radius 0 = everywhere).
|
||||
fn vr_glitch(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let amp = (pr.f("masterAmplitude") / 100.0).max(0.0);
|
||||
if amp <= 0.0 {
|
||||
return b;
|
||||
}
|
||||
let g = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d);
|
||||
let rate = pr.f("distortionRate").max(0.0);
|
||||
let geo = pr.f("geometricDistortion") / 100.0 * amp;
|
||||
let (hdisp, vdisp) = (g("geometric/horizontalDisplacement", 100.0) / 100.0, g("geometric/verticalDisplacement", 0.0) / 100.0);
|
||||
let complexity = pr.f("distortionComplexity").clamp(1.0, 100.0);
|
||||
let rgb = pr.f("colorDistortion") / 100.0 * amp;
|
||||
let offs = [g("color/redOffset", -1.0), g("color/greenOffset", 0.0), g("color/blueOffset", 1.0)];
|
||||
let scan = (pr.f("scanlines") / 100.0 * amp) as f32;
|
||||
let spacing = g("scanlineSpacing", 2.0).round().max(2.0) as usize;
|
||||
let seed = (pr.f("randomSeed") as u32) ^ ctx.seed;
|
||||
// The glitch pattern changes `rate` times a second (held between changes).
|
||||
let slot = if rate > 0.0 { (ctx.time * rate).floor() as i64 } else { 0 } as u32;
|
||||
// Evolution: a fraction of a slot that blends towards the next pattern.
|
||||
let mut evo = g("distortionEvolution", 0.0) / 360.0;
|
||||
if pr.b("evolutionOptions/cycleEvolution") {
|
||||
evo = evo.rem_euclid(g("evolutionOptions/cycle", 1.0).round().max(1.0));
|
||||
}
|
||||
let base = if rate > 0.0 { ctx.time * rate } else { 0.0 } + evo;
|
||||
let slot = base.floor() as i64 as u32;
|
||||
let frac = base - base.floor();
|
||||
// Target Point.
|
||||
let target = mv(&rotation(g("target/tilt", 0.0), g("target/pan", 0.0), 0.0), [0.0, 0.0, 1.0]);
|
||||
let radius = g("target/radius", 0.0).to_radians();
|
||||
let feather = g("target/feather", 0.0).to_radians();
|
||||
b.img = per_eye(&b.img, layout(ctx), |e, eye| {
|
||||
let (w, h) = (e.width as f64, e.height as f64);
|
||||
let bands = complexity.round() as u32;
|
||||
let mut o = Image::new(e.width, e.height);
|
||||
o.rows_mut().for_each(|(y, row)| {
|
||||
let band = ((y as f64 / h) * bands as f64) as u32;
|
||||
let r0 = hash1(band, slot, seed ^ (eye as u32 * 977));
|
||||
let on = r0 < 0.35;
|
||||
let shift = if on { (hash1(band, slot + 1, seed) as f64 * 2.0 - 1.0) * geo * w * 0.15 } else { 0.0 };
|
||||
let split = if on { rgb * w * 0.01 * (1.0 + hash1(band, slot + 2, seed) as f64) } else { 0.0 };
|
||||
let dark = if y % 2 == 0 { 1.0 - scan * 0.5 } else { 1.0 };
|
||||
let at = |s: u32| {
|
||||
let r0 = hash1(band, s, seed ^ (eye as u32 * 977));
|
||||
let on = r0 < 0.35;
|
||||
let shift = if on { (hash1(band, s + 1, seed) as f64 * 2.0 - 1.0) * geo } else { 0.0 };
|
||||
let split = if on { rgb * w * 0.01 * (1.0 + hash1(band, s + 2, seed) as f64) } else { 0.0 };
|
||||
(shift, split)
|
||||
};
|
||||
let (s0, p0) = at(slot);
|
||||
let (shift, split) = if frac > 0.0 {
|
||||
let (s1, p1) = at(slot + 1);
|
||||
(s0 + (s1 - s0) * frac, p0 + (p1 - p0) * frac)
|
||||
} else {
|
||||
(s0, p0)
|
||||
};
|
||||
let (dx, dy) = (shift * w * 0.15 * hdisp, shift * h * 0.15 * vdisp);
|
||||
let dark = if y % spacing == 0 { 1.0 - scan * 0.5 } else { 1.0 };
|
||||
for (x, px) in row.iter_mut().enumerate() {
|
||||
let sx = x as f64 + 0.5 - shift;
|
||||
let sy = y as f64 + 0.5;
|
||||
let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let k = if radius > 0.0 {
|
||||
let a = angle(equi_dir(fx, fy, w, h), target);
|
||||
if a <= radius {
|
||||
1.0
|
||||
} else if feather > 0.0 {
|
||||
(1.0 - (a - radius) / feather).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let orig = e.data[y * e.width as usize + x];
|
||||
if k <= 0.0 {
|
||||
*px = orig;
|
||||
continue;
|
||||
}
|
||||
let (sx, sy) = (fx - dx * k, (fy - dy * k).clamp(0.5, h - 0.5));
|
||||
// Horizontal shifts wrap around the seam (the frame is a full turn).
|
||||
let g = sample_equi(e, sx, sy);
|
||||
let r = sample_equi(e, sx - split, sy);
|
||||
let bb = sample_equi(e, sx + split, sy);
|
||||
*px = [r[0] * dark, g[1] * dark, bb[2] * dark, g[3].max(r[3]).max(bb[3])];
|
||||
let c: [Px; 3] = std::array::from_fn(|i| sample_equi(e, sx + offs[i] * split * k, sy));
|
||||
let d = 1.0 - (1.0 - dark) * k as f32;
|
||||
*px = [c[0][0] * d, c[1][1] * d, c[2][2] * d, c[0][3].max(c[1][3]).max(c[2][3])];
|
||||
}
|
||||
});
|
||||
o
|
||||
|
|
@ -947,9 +1042,22 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
p("masterAmplitude", "Master Amplitude", num(100.0), slider(0.0, 500.0, 0.0, 200.0, 1)),
|
||||
p("distortionRate", "Distortion Rate", num(5.0), slider(0.0, 60.0, 0.0, 30.0, 2)),
|
||||
p("distortionComplexity", "Distortion Complexity", num(16.0), slider(1.0, 100.0, 1.0, 64.0, 0)),
|
||||
ang("distortionEvolution", "Distortion Evolution"),
|
||||
p("evolutionOptions/cycleEvolution", "Cycle Evolution", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("evolutionOptions/cycle", "Cycle (in Revolutions)", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)),
|
||||
p("geometricDistortion", "Geometric Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("geometric/horizontalDisplacement", "Horizontal Displacement", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
p("geometric/verticalDisplacement", "Vertical Displacement", num(0.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
p("colorDistortion", "Color Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("color/redOffset", "Red Offset", num(-1.0), slider(-10.0, 10.0, -2.0, 2.0, 2)),
|
||||
p("color/greenOffset", "Green Offset", num(0.0), slider(-10.0, 10.0, -2.0, 2.0, 2)),
|
||||
p("color/blueOffset", "Blue Offset", num(1.0), slider(-10.0, 10.0, -2.0, 2.0, 2)),
|
||||
p("scanlines", "Scanlines", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("scanlineSpacing", "Scanline Spacing", num(2.0), slider(2.0, 100.0, 2.0, 20.0, 0)),
|
||||
ang("target/tilt", "Target Tilt"),
|
||||
ang("target/pan", "Target Pan"),
|
||||
p("target/radius", "Target Radius", num(0.0), slider(0.0, 180.0, 0.0, 180.0, 1)),
|
||||
p("target/feather", "Target Feather", num(0.0), slider(0.0, 180.0, 0.0, 90.0, 1)),
|
||||
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)),
|
||||
],
|
||||
vr_glitch,
|
||||
|
|
@ -1056,7 +1164,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn projections_are_inverse() {
|
||||
for kind in 0..6u32 {
|
||||
for kind in 0..9u32 {
|
||||
let pr = Proj { kind, fov: if kind == 4 { 360.0 } else { 100.0 } };
|
||||
let (w, h) = (240.0, 120.0);
|
||||
for (x, y) in [(10.5, 20.5), (100.5, 60.5), (230.5, 110.5), (130.5, 33.5)] {
|
||||
|
|
@ -1070,7 +1178,7 @@ mod tests {
|
|||
#[test]
|
||||
fn converter_round_trips_equirect_through_cube_maps() {
|
||||
let img = sphere_img(128, 64);
|
||||
for target in [1u32, 2, 3] {
|
||||
for target in [1u32, 2, 3, 6, 7] {
|
||||
let cube = run_fx("ec.vr.converter", &[("targetProjection", Value::Enum(target))], img.clone(), 0.0, EffectEnv::default());
|
||||
let back = run_fx(
|
||||
"ec.vr.converter",
|
||||
|
|
@ -1098,6 +1206,41 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_glitch_full_controls() {
|
||||
let img = sphere_img(128, 64);
|
||||
let run = |vals: &[(&str, Value)], t: f64| run_fx("ec.vr.digitalglitch", vals, img.clone(), t, EffectEnv::default()).img;
|
||||
let base = run(&[], 0.0);
|
||||
for (id, v) in
|
||||
[("distortionEvolution", num(90.0)), ("geometric/verticalDisplacement", num(100.0)), ("color/redOffset", num(3.0)), ("scanlineSpacing", num(5.0))]
|
||||
{
|
||||
assert_ne!(run(&[(id, v.clone())], 0.0), base, "{id}");
|
||||
}
|
||||
// Cycle Evolution: one full cycle is the start again.
|
||||
let cyc =
|
||||
|e: f64| run(&[("distortionEvolution", num(e)), ("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(1.0))], 0.0);
|
||||
assert_eq!(cyc(0.0), cyc(360.0));
|
||||
// Target Point: a small region around the front; the back stays untouched.
|
||||
let t = run(&[("target/radius", num(30.0))], 0.0);
|
||||
assert_eq!(t.get(0, 32), img.get(0, 32), "behind the viewer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converter_fulldome_and_eac() {
|
||||
let img = sphere_img(128, 64);
|
||||
let dome = run_fx("ec.vr.converter", &[("targetProjection", Value::Enum(8))], img.clone(), 0.0, EffectEnv::default()).img;
|
||||
// The centre looks at the zenith (+Y); the bottom edge at the front (+Z).
|
||||
let c = dome.get(64, 32);
|
||||
assert!((c[1] - 0.9).abs() < 0.03, "{c:?}");
|
||||
let f = dome.get(64, 62);
|
||||
assert!(f[2] > 0.8, "{f:?}");
|
||||
// EAC faces are equi-angular: a quarter of the front face sits at 22.5 degrees.
|
||||
let eac = Proj { kind: 7, fov: 90.0 };
|
||||
let cw = 128.0 / 3.0;
|
||||
let d = eac.dir(cw + 0.25 * cw, 16.0, 128.0, 64.0).unwrap();
|
||||
assert!((d[0].atan2(d[2]).to_degrees() + 22.5).abs() < 0.5, "{d:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_sphere_pans_and_round_trips() {
|
||||
let img = sphere_img(96, 48);
|
||||
|
|
|
|||
|
|
@ -105,6 +105,10 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> {
|
|||
}
|
||||
|
||||
fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
// Controls the kernels do not implement render on the CPU.
|
||||
if !effectcraft_effects::catalog::gpu_supported(id, ctx) {
|
||||
return None;
|
||||
}
|
||||
match id {
|
||||
"ec.blur.gaussian" => gaussian(e, ctx, b),
|
||||
"ec.blur.fastbox" => box_blur(e, ctx, b),
|
||||
|
|
@ -218,6 +222,10 @@ fn directional(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
|||
}
|
||||
|
||||
fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
// Other Glow Operations and the Arbitrary Map render on the CPU.
|
||||
if ctx.params.e("colors") == 2 || effectcraft_effects::glow_operation(ctx) != effectcraft_color::BlendMode::Add {
|
||||
return None;
|
||||
}
|
||||
let thr = ctx.params.f("threshold") as f32 / 100.0;
|
||||
let radius = ctx.params.f("radius") * b.scale;
|
||||
let intensity = ctx.params.f("intensity") as f32;
|
||||
|
|
@ -276,6 +284,10 @@ fn drop_shadow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
|||
}
|
||||
|
||||
fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
// Motion blur (shutter angle) renders on the CPU.
|
||||
if ctx.env.host.is_some() && effectcraft_effects::transform_shutter(ctx).is_some() {
|
||||
return None;
|
||||
}
|
||||
let anchor = b.to_px(ctx.params.v2("anchor"));
|
||||
let pos = b.to_px(ctx.params.v2("position"));
|
||||
let sh = ctx.params.f("scaleHeight");
|
||||
|
|
@ -352,11 +364,19 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
|||
p.f[0] = [br, k, ct, 0.0];
|
||||
}
|
||||
"ec.color.huesaturation" => {
|
||||
// Colour ranges (Channel Control) render on the CPU.
|
||||
if !effectcraft_effects::huesat_ranges_identity(ctx) {
|
||||
return None;
|
||||
}
|
||||
p.u[0] = [3, ctx.params.b("colorize") as u32, 0, 0];
|
||||
p.f[0] = [f("hue") as f32 / 360.0, f("saturation") as f32 / 100.0, f("lightness") as f32 / 100.0, 0.0];
|
||||
p.f[1] = [f("colorizeHue") as f32 / 360.0, f("colorizeSaturation") as f32 / 100.0, f("colorizeLightness") as f32 / 100.0, 0.0];
|
||||
}
|
||||
"ec.color.levels" => {
|
||||
// Red / Green / Blue / Alpha controls render on the CPU.
|
||||
if !effectcraft_effects::levels_channels_identity(ctx) {
|
||||
return None;
|
||||
}
|
||||
let (clip_b, clip_w) = effectcraft_effects::levels_clip(ctx);
|
||||
p.u[0] = [4, clip_b as u32, clip_w as u32, 0];
|
||||
p.f[0] = [f("inBlack") as f32, f("inWhite") as f32, f("gamma").max(0.01) as f32, f("outBlack") as f32];
|
||||
|
|
@ -382,6 +402,10 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
|||
p.f[0][0] = 1.0 - f("blend") as f32 / 100.0;
|
||||
}
|
||||
"ec.generate.fill" => {
|
||||
// Fill Mask / All Masks render on the CPU.
|
||||
if effectcraft_effects::fill_uses_masks(ctx) {
|
||||
return None;
|
||||
}
|
||||
p.u[0] = [7, ctx.params.b("invert") as u32, 0, 0];
|
||||
p.f[0] = ctx.params.color("color");
|
||||
p.f[1][0] = f("opacity") as f32 / 100.0;
|
||||
|
|
|
|||
|
|
@ -59,15 +59,15 @@ fn rgb(c: [f32; 4]) -> [f32; 4] {
|
|||
|
||||
/// Same selective colour ids as `color2::SC_IDS`.
|
||||
const SC_IDS: [[&str; 4]; 9] = [
|
||||
["redsCyan", "redsMagenta", "redsYellow", "redsBlack"],
|
||||
["yellowsCyan", "yellowsMagenta", "yellowsYellow", "yellowsBlack"],
|
||||
["greensCyan", "greensMagenta", "greensYellow", "greensBlack"],
|
||||
["cyansCyan", "cyansMagenta", "cyansYellow", "cyansBlack"],
|
||||
["bluesCyan", "bluesMagenta", "bluesYellow", "bluesBlack"],
|
||||
["magentasCyan", "magentasMagenta", "magentasYellow", "magentasBlack"],
|
||||
["whitesCyan", "whitesMagenta", "whitesYellow", "whitesBlack"],
|
||||
["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"],
|
||||
["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"],
|
||||
["details/reds/redsCyan", "details/reds/redsMagenta", "details/reds/redsYellow", "details/reds/redsBlack"],
|
||||
["details/yellows/yellowsCyan", "details/yellows/yellowsMagenta", "details/yellows/yellowsYellow", "details/yellows/yellowsBlack"],
|
||||
["details/greens/greensCyan", "details/greens/greensMagenta", "details/greens/greensYellow", "details/greens/greensBlack"],
|
||||
["details/cyans/cyansCyan", "details/cyans/cyansMagenta", "details/cyans/cyansYellow", "details/cyans/cyansBlack"],
|
||||
["details/blues/bluesCyan", "details/blues/bluesMagenta", "details/blues/bluesYellow", "details/blues/bluesBlack"],
|
||||
["details/magentas/magentasCyan", "details/magentas/magentasMagenta", "details/magentas/magentasYellow", "details/magentas/magentasBlack"],
|
||||
["details/whites/whitesCyan", "details/whites/whitesMagenta", "details/whites/whitesYellow", "details/whites/whitesBlack"],
|
||||
["details/neutrals/neutralsCyan", "details/neutrals/neutralsMagenta", "details/neutrals/neutralsYellow", "details/neutrals/neutralsBlack"],
|
||||
["details/blacks/blacksCyan", "details/blacks/blacksMagenta", "details/blacks/blacksYellow", "details/blacks/blacksBlack"],
|
||||
];
|
||||
|
||||
fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
|
|
|
|||
|
|
@ -32,10 +32,24 @@ fn black_white_colorama_selective_color() {
|
|||
effect_case("ec.color.colorama", &[]);
|
||||
effect_case("ec.color.colorama", &[("inputPhase/mode", e(4)), ("inputPhase/phaseShift", n(75.0)), ("outputCycle/cycles", n(2.5)), ("blend", n(40.0))]);
|
||||
effect_case("ec.color.colorama", &[("inputPhase/mode", e(8)), ("outputCycle/cycles", n(1.3))]);
|
||||
effect_case("ec.color.selectivecolor", &[("redsCyan", n(60.0)), ("bluesYellow", n(-40.0)), ("neutralsBlack", n(20.0)), ("whitesMagenta", n(30.0))]);
|
||||
effect_case(
|
||||
"ec.color.selectivecolor",
|
||||
&[("method", e(1)), ("greensMagenta", n(50.0)), ("yellowsCyan", n(-30.0)), ("blacksYellow", n(40.0)), ("magentasBlack", n(25.0))],
|
||||
&[
|
||||
("details/reds/redsCyan", n(60.0)),
|
||||
("details/blues/bluesYellow", n(-40.0)),
|
||||
("details/neutrals/neutralsBlack", n(20.0)),
|
||||
("details/whites/whitesMagenta", n(30.0)),
|
||||
],
|
||||
);
|
||||
effect_case(
|
||||
"ec.color.selectivecolor",
|
||||
&[
|
||||
("method", e(1)),
|
||||
("details/greens/greensMagenta", n(50.0)),
|
||||
("details/yellows/yellowsCyan", n(-30.0)),
|
||||
("details/blacks/blacksYellow", n(40.0)),
|
||||
("details/magentas/magentasBlack", n(25.0)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,18 +95,28 @@ fn sad(a: &Plane, b: &Plane, bx: i64, by: i64, bs: i64, dx: i64, dy: i64) -> f32
|
|||
/// Block-matching flow from `a` to `b` (same size). `block` is the tile size at full resolution;
|
||||
/// `radius` the search range at the coarsest level (motion up to about `radius · 2^levels`).
|
||||
pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow {
|
||||
let bs = block.max(4) as usize;
|
||||
block_flow_window(a, b, block, block, radius)
|
||||
}
|
||||
|
||||
/// [`block_flow`] with separate vector spacing and matching window: one vector per
|
||||
/// `tile × tile` tile, each matched with the `window × window` block centred on its tile
|
||||
/// (Timewarp's Vector Detail and Block Size).
|
||||
pub fn block_flow_window(a: &Image, b: &Image, tile: u32, window: u32, radius: i32) -> Flow {
|
||||
let bs = tile.max(2) as usize;
|
||||
let win = window.max(4) as i64;
|
||||
// Window origin relative to the tile origin.
|
||||
let wo = bs as i64 / 2 - win / 2;
|
||||
let (w, h) = (a.width as usize, a.height as usize);
|
||||
let cols = w.div_ceil(bs).max(1);
|
||||
let rows = h.div_ceil(bs).max(1);
|
||||
let mut flow = Flow { width: a.width, height: a.height, block: bs as u32, cols, rows, v: vec![[0.0; 2]; cols * rows] };
|
||||
if a.width != b.width || a.height != b.height || w < bs || h < bs {
|
||||
if a.width != b.width || a.height != b.height || w < bs || h < bs || (w as i64) < win || (h as i64) < win {
|
||||
return flow;
|
||||
}
|
||||
// Pyramids (level 0 = full resolution); stop while blocks still fit.
|
||||
let mut pa = vec![Plane::luma(a)];
|
||||
let mut pb = vec![Plane::luma(b)];
|
||||
while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= bs * 2 && p.h / 2 >= bs * 2) {
|
||||
while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= (bs as i64).max(win) as usize * 2 && p.h / 2 >= (bs as i64).max(win) as usize * 2) {
|
||||
let (na, nb) = (pa.last().map(Plane::half), pb.last().map(Plane::half));
|
||||
pa.extend(na);
|
||||
pb.extend(nb);
|
||||
|
|
@ -118,15 +128,15 @@ pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow {
|
|||
let (la, lb) = (&pa[level], &pb[level]);
|
||||
let k = 1i64 << level;
|
||||
// The block covers the tile's footprint at this level (at least 4 pixels).
|
||||
let lbs = ((bs as i64) / k).max(4);
|
||||
let lbs = (win / k).max(4);
|
||||
let r = if level == top { radius as i64 } else { 1 };
|
||||
let prev = cur.clone();
|
||||
cur = (0..cols * rows)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let (c, rr) = ((i % cols) as i64, (i / cols) as i64);
|
||||
let bx = c * bs as i64 / k;
|
||||
let by = rr * bs as i64 / k;
|
||||
let bx = (c * bs as i64 + wo) / k;
|
||||
let by = (rr * bs as i64 + wo) / k;
|
||||
// Candidates: this tile's and its neighbours' vectors from the coarser level.
|
||||
let mut seeds: Vec<[i64; 2]> = vec![[0, 0]];
|
||||
if level != top {
|
||||
|
|
@ -160,18 +170,19 @@ pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow {
|
|||
// Sub-pixel refinement (parabola through the SAD of the neighbours) at full resolution.
|
||||
let (la, lb) = (&pa[0], &pb[0]);
|
||||
let bsi = bs as i64;
|
||||
let wsi = win;
|
||||
let fine: Vec<[f32; 2]> = (0..cols * rows)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let (bx, by) = ((i % cols) as i64 * bsi, (i / cols) as i64 * bsi);
|
||||
let (bx, by) = ((i % cols) as i64 * bsi + wo, (i / cols) as i64 * bsi + wo);
|
||||
let d = cur[i];
|
||||
let e0 = sad(la, lb, bx, by, bsi, d[0], d[1]);
|
||||
let e0 = sad(la, lb, bx, by, wsi, d[0], d[1]);
|
||||
let fit = |em: f32, ep: f32| {
|
||||
let den = em - 2.0 * e0 + ep;
|
||||
if den > 1e-6 { (0.5 * (em - ep) / den).clamp(-0.5, 0.5) } else { 0.0 }
|
||||
};
|
||||
let sx = fit(sad(la, lb, bx, by, bsi, d[0] - 1, d[1]), sad(la, lb, bx, by, bsi, d[0] + 1, d[1]));
|
||||
let sy = fit(sad(la, lb, bx, by, bsi, d[0], d[1] - 1), sad(la, lb, bx, by, bsi, d[0], d[1] + 1));
|
||||
let sx = fit(sad(la, lb, bx, by, wsi, d[0] - 1, d[1]), sad(la, lb, bx, by, wsi, d[0] + 1, d[1]));
|
||||
let sy = fit(sad(la, lb, bx, by, wsi, d[0], d[1] - 1), sad(la, lb, bx, by, wsi, d[0], d[1] + 1));
|
||||
[d[0] as f32 + sx, d[1] as f32 + sy]
|
||||
})
|
||||
.collect();
|
||||
|
|
|
|||
|
|
@ -157,6 +157,38 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
|||
}
|
||||
}
|
||||
|
||||
fn params_at(&self, layer_time: f64) -> Option<Params> {
|
||||
let i = self.index.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let g = self.layer.effects()?.groups().nth(i)?;
|
||||
let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time)));
|
||||
Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr)))
|
||||
}
|
||||
|
||||
fn comp_scene(&self) -> Option<effectcraft_effects::CompScene> {
|
||||
// The comp camera's view of the layer (as if 3D at its transform), brought back into the
|
||||
// layer's own pixel grid through the inverse of how the layer itself composites.
|
||||
let world = self.ctx.world_matrix(self.layer);
|
||||
let (cam, _, _) = self.ctx.camera();
|
||||
let p = (cam * world).0;
|
||||
let back = self.ctx.layer_to_comp(self.layer).0.inverse()?.0;
|
||||
let rows = [p[0], p[1], p[3]];
|
||||
let camera: [[f64; 4]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| back[i][k] * rows[k][j]).sum()));
|
||||
let inv = world.inverse();
|
||||
let light = three_d::light::lights_at(self.ctx).first().and_then(|l| {
|
||||
let inv = inv.as_ref()?;
|
||||
let pos = inv.apply(l.pos);
|
||||
let dir = inv.apply_vec(l.dir);
|
||||
let len = (dir.x * dir.x + dir.y * dir.y + dir.z * dir.z).sqrt().max(1e-12);
|
||||
let kind = match l.kind {
|
||||
effectcraft_project::LightKind::Parallel => 0,
|
||||
effectcraft_project::LightKind::Ambient => 2,
|
||||
_ => 1,
|
||||
};
|
||||
Some(effectcraft_effects::CompLight { pos: [pos.x, pos.y, pos.z], dir: [dir.x / len, dir.y / len, dir.z / len], color: l.color, kind })
|
||||
});
|
||||
Some(effectcraft_effects::CompScene { camera: Some(camera), light })
|
||||
}
|
||||
|
||||
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
|
||||
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
|
||||
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
|
||||
|
|
@ -741,6 +773,11 @@ impl<'a> Renderer<'a> {
|
|||
effect_index: 0,
|
||||
working_space: self.pipe.space,
|
||||
working_linear: self.pipe.linear,
|
||||
shutter: (ctx.comp.enable_motion_blur && layer.switches.motion_blur).then_some((
|
||||
ctx.comp.shutter_angle,
|
||||
ctx.comp.shutter_phase,
|
||||
ctx.comp.motion_blur_samples,
|
||||
)),
|
||||
};
|
||||
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
|
||||
let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx
|
||||
|
|
|
|||
|
|
@ -163,6 +163,19 @@ fn effects_run_in_pipeline() {
|
|||
assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}");
|
||||
}
|
||||
|
||||
/// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place.
|
||||
#[test]
|
||||
fn card_dance_comp_camera_matches_the_default_view() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, [0.2, 0.6, 1.0], 50, 50);
|
||||
add_effect(&mut p, &mut l, "ec.sim.carddance", [50.0, 50.0], &[("cameraSystem", Value::Enum(2))]);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
let c = img.get(100, 50);
|
||||
assert!((c[0] - 0.2).abs() < 0.02 && (c[2] - 1.0).abs() < 0.02 && c[3] > 0.99, "{c:?}");
|
||||
assert!(img.get(10, 50)[3] < 0.01);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ layer cache
|
||||
|
||||
fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
|
||||
|
|
|
|||
|
|
@ -441,6 +441,37 @@ fn dial_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, layer: &Layer, prop: &P
|
|||
}
|
||||
}
|
||||
|
||||
/// The effect instance a row belongs to, for conditional visibility
|
||||
/// ([`effectcraft_engine::effects::catalog::param_shown`]): effect id, the instance's root
|
||||
/// group and the spec-id path of the group being drawn (`""` or `cameraPosition/`).
|
||||
struct FxScope<'a> {
|
||||
effect: &'a str,
|
||||
root: &'a PropGroup,
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl<'a> FxScope<'a> {
|
||||
fn sub(&self, m: &str) -> FxScope<'a> {
|
||||
FxScope { effect: self.effect, root: self.root, path: format!("{}{m}/", self.path) }
|
||||
}
|
||||
/// Whether child `m` of the current group is shown.
|
||||
fn shown(&self, layer: &Layer, ectx: &EvalCtx, m: &str) -> bool {
|
||||
let root = self.root;
|
||||
let value = |id: &str| -> Option<Value> {
|
||||
let mut g = root;
|
||||
let mut parts = id.split('/').peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
if parts.peek().is_none() {
|
||||
return g.get(part).map(|p| ectx.value(layer, p));
|
||||
}
|
||||
g = g.sub(part)?;
|
||||
}
|
||||
None
|
||||
};
|
||||
effectcraft_engine::effects::catalog::param_shown(self.effect, &format!("{}{m}", self.path), &value)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn group_rows(
|
||||
app: &mut EffectcraftApp,
|
||||
|
|
@ -452,6 +483,7 @@ fn group_rows(
|
|||
rect: Rect,
|
||||
y: &mut f32,
|
||||
depth: usize,
|
||||
fx: &FxScope,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
|
|
@ -459,7 +491,7 @@ fn group_rows(
|
|||
let r = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), ROW));
|
||||
match c {
|
||||
Node::Prop(pr) => {
|
||||
if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() {
|
||||
if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() || !fx.shown(layer, ectx, &pr.match_id) {
|
||||
continue;
|
||||
}
|
||||
*y += ROW;
|
||||
|
|
@ -486,7 +518,7 @@ fn group_rows(
|
|||
}
|
||||
Node::Group(sg) => {
|
||||
// Paint strokes live in the Timeline only (AE's Paint shows Paint on Transparent).
|
||||
if effectcraft_engine::effects::paint::is_paint(g) {
|
||||
if effectcraft_engine::effects::paint::is_paint(g) || !fx.shown(layer, ectx, &sg.match_id) {
|
||||
continue;
|
||||
}
|
||||
*y += ROW;
|
||||
|
|
@ -503,7 +535,8 @@ fn group_rows(
|
|||
p.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &sg.name, Tokens::ui(12.0), t.text);
|
||||
}
|
||||
if open {
|
||||
group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, actions);
|
||||
let sub = fx.sub(&sg.match_id);
|
||||
group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, &sub, actions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -642,12 +675,24 @@ fn channel_popup(
|
|||
fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, ectx: &EvalCtx, r: Rect, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
let euid = g.uid;
|
||||
let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(4);
|
||||
// The channel lives in the effect's hidden Channel parameter (UI state for instances
|
||||
// saved before it existed).
|
||||
let chan_prop = g.get("channel");
|
||||
let ch = match chan_prop {
|
||||
Some(pr) => ectx.value(layer, pr).as_enum() as usize,
|
||||
None => app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0),
|
||||
}
|
||||
.min(4);
|
||||
let x0 = r.min.x + 30.0;
|
||||
let cy = r.min.y + 17.0;
|
||||
let names: Vec<&str> = fw::CURVE_CHANNELS.iter().map(|c| c.1).collect();
|
||||
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel")) {
|
||||
app.ui.fx_curve_channel.insert(euid, n);
|
||||
match chan_prop {
|
||||
Some(pr) => actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": n}))),
|
||||
None => {
|
||||
app.ui.fx_curve_channel.insert(euid, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
let pencil = app.ui.fx_curve_pencil.contains(&euid);
|
||||
let mr = Rect::from_min_size(pos2(x0 + 146.0, cy - 9.0), vec2(56.0, 18.0));
|
||||
|
|
@ -696,7 +741,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
|||
/// Levels parameter ids for a channel: input black, input white, gamma, output black, output white.
|
||||
fn levels_ids(effect: &str, ch: usize) -> [String; 5] {
|
||||
if effect == "ec.color.levels" {
|
||||
return ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].map(String::from);
|
||||
return effectcraft_engine::effects::levels_channel_ids(ch);
|
||||
}
|
||||
// Levels (Individual Controls) nests each channel's controls in its twirl-down group.
|
||||
let pre = fw::LEVELS_CHANNELS[ch.min(4)].0;
|
||||
|
|
@ -721,7 +766,13 @@ fn levels_editor(
|
|||
let euid = g.uid;
|
||||
let x0 = r.min.x + 30.0;
|
||||
let cy = r.min.y + 17.0;
|
||||
let ch = if effect == "ec.color.levelsic" { app.ui.fx_levels_channel.get(&euid).copied().unwrap_or(0).min(4) } else { 0 };
|
||||
// Levels keeps its channel in the Channel parameter; Levels (Individual Controls) shows
|
||||
// every channel and the popup only picks the histogram.
|
||||
let ch = if effect == "ec.color.levelsic" {
|
||||
app.ui.fx_levels_channel.get(&euid).copied().unwrap_or(0).min(4)
|
||||
} else {
|
||||
g.get("channel").map(|pr| ectx.value(layer, pr).as_enum() as usize).unwrap_or(0).min(4)
|
||||
};
|
||||
if effect == "ec.color.levelsic" {
|
||||
let names: Vec<&str> = fw::LEVELS_CHANNELS.iter().map(|c| c.1).collect();
|
||||
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel"))
|
||||
|
|
@ -959,7 +1010,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
}
|
||||
group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &mut actions);
|
||||
let scope = FxScope { effect: effect.as_str(), root: g, path: String::new() };
|
||||
group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &scope, &mut actions);
|
||||
}
|
||||
y += 4.0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
|
||||
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
|
||||
|
||||
EffectCraft ships 306 effects with 2488 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
EffectCraft ships 306 effects with 3009 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
|
||||
- **GPU**: 59 effects also run on the GPU compositor with identical results.
|
||||
- **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||
- **Status**: 264 implemented in full, 42 partial (what is missing is listed).
|
||||
- **Status**: 306 implemented in full, 0 partial (what is missing is listed).
|
||||
|
||||
Effects are addressed by id (`ec.<category>.<name>`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`).
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
|
||||
| Effect | Id | Params | GPU | 32 | Status |
|
||||
|---|---|---:|:-:|:-:|---|
|
||||
| 3D Channel Extract | `ec.3d.channelextract` | 5 | | 32 | Partial: no anti-alias option |
|
||||
| 3D Channel Extract | `ec.3d.channelextract` | 6 | | 32 | Implemented |
|
||||
| Cryptomatte | `ec.3d.cryptomatte` | 4 | | 32 | Implemented |
|
||||
| Depth Matte | `ec.3d.depthmatte` | 3 | | 32 | Implemented |
|
||||
| Depth of Field | `ec.3d.depthoffield` | 4 | | 32 | Implemented |
|
||||
|
|
@ -51,7 +51,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| CC Radial Blur | `ec.blur.ccradial` | 4 | | 32 | Implemented |
|
||||
| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented |
|
||||
| CC Vector Blur | `ec.blur.ccvector` | 4 | | 32 | Implemented |
|
||||
| Camera Lens Blur | `ec.blur.cameralens` | 10 | GPU | 32 | Partial: no Diffraction Fringe or Blur Map layer |
|
||||
| Camera Lens Blur | `ec.blur.cameralens` | 16 | GPU | 32 | Implemented |
|
||||
| Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented |
|
||||
| Channel Blur | `ec.blur.channel` | 6 | | 32 | Implemented |
|
||||
| Compound Blur | `ec.blur.compound` | 4 | | 32 | Implemented |
|
||||
|
|
@ -71,10 +71,10 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| Blend | `ec.channel.blend` | 4 | | 32 | Implemented |
|
||||
| CC Composite | `ec.channel.cccomposite` | 3 | | 32 | Implemented |
|
||||
| Calculations | `ec.channel.calculations` | 9 | | 32 | Implemented |
|
||||
| Channel Combiner | `ec.channel.combiner` | 6 | | 32 | Partial: no Saturation Multiplied target |
|
||||
| Channel Combiner | `ec.channel.combiner` | 6 | | 32 | Implemented |
|
||||
| Compound Arithmetic | `ec.channel.compoundarithmetic` | 6 | | 32 | Implemented |
|
||||
| Invert | `ec.channel.invert` | 2 | GPU | 32 | Implemented |
|
||||
| Minimax | `ec.channel.minimax` | 4 | | 32 | Partial: no Don't Shrink Edges option |
|
||||
| Minimax | `ec.channel.minimax` | 5 | | 32 | Implemented |
|
||||
| Remove Color Matting | `ec.channel.removecolormatting` | 2 | | 32 | Implemented |
|
||||
| Set Channels | `ec.channel.setchannels` | 9 | | 32 | Implemented |
|
||||
| Set Matte | `ec.channel.setmatte` | 6 | | 32 | Implemented |
|
||||
|
|
@ -102,16 +102,16 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | | 32 | Implemented |
|
||||
| Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented |
|
||||
| Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented |
|
||||
| Colorama | `ec.color.colorama` | 5 | GPU | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking |
|
||||
| Curves | `ec.color.curves` | 0 | GPU | 32 | Partial: no Channel popup |
|
||||
| Colorama | `ec.color.colorama` | 20 | GPU | 32 | Implemented |
|
||||
| Curves | `ec.color.curves` | 0 | GPU | 32 | Implemented |
|
||||
| Equalize | `ec.color.equalize` | 2 | | 32 | Implemented |
|
||||
| Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented |
|
||||
| Gamma/Pedestal/Gain | `ec.color.gammapedestalgain` | 10 | | 32 | Implemented |
|
||||
| Hue/Saturation | `ec.color.huesaturation` | 7 | GPU | 32 | Partial: no per-range Channel Control |
|
||||
| Hue/Saturation | `ec.color.huesaturation` | 50 | GPU | 32 | Implemented |
|
||||
| Leave Color | `ec.color.leavecolor` | 5 | | 32 | Implemented |
|
||||
| Levels | `ec.color.levels` | 7 | GPU | 32 | Partial: master channel only (no Channel popup) |
|
||||
| Levels | `ec.color.levels` | 36 | GPU | 32 | Implemented |
|
||||
| Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 32 | Implemented |
|
||||
| Lumetri Color | `ec.color.lumetri` | 38 | GPU | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files |
|
||||
| Lumetri Color | `ec.color.lumetri` | 66 | GPU | 32 | Implemented |
|
||||
| OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented |
|
||||
| OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | | 32 | Implemented |
|
||||
| OCIO Display Transform | `ec.color.ociodisplay` | 9 | | 32 | Implemented |
|
||||
|
|
@ -119,7 +119,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented |
|
||||
| PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented |
|
||||
| Photo Filter | `ec.color.photofilter` | 4 | | 32 | Implemented |
|
||||
| Selective Color | `ec.color.selectivecolor` | 37 | GPU | 32 | Partial: simplified Colors / Details layout |
|
||||
| Selective Color | `ec.color.selectivecolor` | 38 | GPU | 32 | Implemented |
|
||||
| Shadow/Highlight | `ec.color.shadowhighlight` | 14 | | 32 | Implemented |
|
||||
| Tint | `ec.color.tint` | 3 | GPU | 32 | Implemented |
|
||||
| Tritone | `ec.color.tritone` | 4 | GPU | 32 | Implemented |
|
||||
|
|
@ -154,16 +154,16 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| Mesh Warp | `ec.distort.meshwarp` | 3 | GPU | 32 | Implemented |
|
||||
| Mirror | `ec.distort.mirror` | 2 | | 32 | Implemented |
|
||||
| Offset | `ec.distort.offset` | 2 | | 32 | Implemented |
|
||||
| Optics Compensation | `ec.distort.opticscompensation` | 5 | | 32 | Partial: no Optimal Pixels |
|
||||
| Optics Compensation | `ec.distort.opticscompensation` | 6 | | 32 | Implemented |
|
||||
| Polar Coordinates | `ec.distort.polar` | 2 | | 32 | Implemented |
|
||||
| Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented |
|
||||
| Reshape | `ec.distort.reshape` | 6 | | 32 | Partial: no correspondence points |
|
||||
| Reshape | `ec.distort.reshape` | 6 | | 32 | Implemented |
|
||||
| Ripple | `ec.distort.ripple` | 7 | GPU | 32 | Implemented |
|
||||
| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Partial: Pixel Motion Detail has no effect |
|
||||
| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Implemented |
|
||||
| Smear | `ec.distort.smear` | 8 | | 32 | Implemented |
|
||||
| Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented |
|
||||
| Transform | `ec.distort.transform` | 10 | GPU | 32 | Partial: no shutter-angle motion blur |
|
||||
| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Partial: no locked pinning variants |
|
||||
| Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented |
|
||||
| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Implemented |
|
||||
| Twirl | `ec.distort.twirl` | 3 | GPU | 32 | Implemented |
|
||||
| Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | | 32 | Implemented |
|
||||
| Warp | `ec.distort.warp` | 5 | | 32 | Implemented |
|
||||
|
|
@ -189,7 +189,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| Effect | Id | Params | GPU | 32 | Status |
|
||||
|---|---|---:|:-:|:-:|---|
|
||||
| 4-Color Gradient | `ec.generate.fourcolor` | 12 | GPU | 32 | Implemented |
|
||||
| Advanced Lightning | `ec.generate.advancedlightning` | 16 | | 32 | Partial: no Alpha Obstacle; most Expert Settings missing |
|
||||
| Advanced Lightning | `ec.generate.advancedlightning` | 24 | | 32 | Implemented |
|
||||
| Audio Spectrum | `ec.generate.audiospectrum` | 23 | | 32 | Implemented |
|
||||
| Audio Waveform | `ec.generate.audiowaveform` | 17 | | 32 | Implemented |
|
||||
| Beam | `ec.generate.beam` | 11 | | 32 | Implemented |
|
||||
|
|
@ -198,19 +198,19 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| CC Light Rays | `ec.generate.cclightrays` | 8 | | 32 | Implemented |
|
||||
| CC Light Sweep | `ec.generate.cclightsweep` | 9 | | 32 | Implemented |
|
||||
| CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented |
|
||||
| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Partial: HQ variants render like the standard ones |
|
||||
| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Implemented |
|
||||
| Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented |
|
||||
| Circle | `ec.generate.circle` | 11 | | 32 | Implemented |
|
||||
| Ellipse | `ec.generate.ellipse` | 8 | | 32 | Implemented |
|
||||
| Eyedropper Fill | `ec.generate.eyedropperfill` | 5 | | 32 | Implemented |
|
||||
| Fill | `ec.generate.fill` | 3 | GPU | 32 | Partial: no Fill Mask / All Masks / feather |
|
||||
| Fill | `ec.generate.fill` | 7 | GPU | 32 | Implemented |
|
||||
| Fractal | `ec.generate.fractal` | 21 | | 32 | Implemented |
|
||||
| Gradient Ramp | `ec.generate.gradientramp` | 7 | GPU | 32 | Implemented |
|
||||
| Grid | `ec.generate.grid` | 12 | GPU | 32 | Implemented |
|
||||
| Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented |
|
||||
| Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented |
|
||||
| Radio Waves | `ec.generate.radiowaves` | 17 | | 32 | Partial: no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile |
|
||||
| Scribble | `ec.generate.scribble` | 21 | | 32 | Partial: no caps, joins or Start/End Apply To |
|
||||
| Radio Waves | `ec.generate.radiowaves` | 31 | | 32 | Implemented |
|
||||
| Scribble | `ec.generate.scribble` | 25 | | 32 | Implemented |
|
||||
| Stroke | `ec.generate.stroke` | 11 | | 32 | Implemented |
|
||||
| Vegas | `ec.generate.vegas` | 26 | | 32 | Implemented |
|
||||
| Write-on | `ec.generate.writeon` | 10 | | 32 | Implemented |
|
||||
|
|
@ -222,9 +222,9 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| VR Blur | `ec.vr.blur` | 2 | | 32 | Implemented |
|
||||
| VR Chromatic Aberrations | `ec.vr.chromaticaberrations` | 8 | | 32 | Implemented |
|
||||
| VR Color Gradients | `ec.vr.colorgradients` | 19 | | 32 | Implemented |
|
||||
| VR Converter | `ec.vr.converter` | 8 | | 32 | Partial: common layouts only |
|
||||
| VR Converter | `ec.vr.converter` | 8 | | 32 | Implemented |
|
||||
| VR De-Noise | `ec.vr.denoise` | 4 | | 32 | Implemented |
|
||||
| VR Digital Glitch | `ec.vr.digitalglitch` | 8 | | 32 | Partial: reduced control set |
|
||||
| VR Digital Glitch | `ec.vr.digitalglitch` | 21 | | 32 | Implemented |
|
||||
| VR Fractal Noise | `ec.vr.fractalnoise` | 16 | | 32 | Implemented |
|
||||
| VR Glow | `ec.vr.glow` | 7 | | 32 | Implemented |
|
||||
| VR Plane to Sphere | `ec.vr.planetosphere` | 6 | | 32 | Implemented |
|
||||
|
|
@ -236,15 +236,15 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
|
||||
| Effect | Id | Params | GPU | 32 | Status |
|
||||
|---|---|---:|:-:|:-:|---|
|
||||
| Advanced Spill Suppressor | `ec.key.advancedspill` | 5 | | 32 | Partial: no Tolerance, Desaturate or Spill Color Correction |
|
||||
| Advanced Spill Suppressor | `ec.key.advancedspill` | 8 | | 32 | Implemented |
|
||||
| CC Simple Wire Removal | `ec.key.ccsimplewireremoval` | 7 | | 32 | Implemented |
|
||||
| Color Difference Key | `ec.key.colordifference` | 16 | | 32 | Implemented |
|
||||
| Color Range | `ec.key.colorrange` | 8 | | 32 | Implemented |
|
||||
| Difference Matte | `ec.key.differencematte` | 6 | | 32 | Implemented |
|
||||
| Extract | `ec.key.extract` | 6 | | 32 | Implemented |
|
||||
| Inner/Outer Key | `ec.key.innerouter` | 10 | | 32 | Partial: one additional mask per side; no cleanup strokes |
|
||||
| Inner/Outer Key | `ec.key.innerouter` | 88 | | 32 | Implemented |
|
||||
| Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented |
|
||||
| Key Light | `ec.keying.keylight` | 42 | GPU | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing |
|
||||
| Key Light | `ec.keying.keylight` | 56 | GPU | 32 | Implemented |
|
||||
| Linear Color Key | `ec.key.linearcolor` | 6 | GPU | 32 | Implemented |
|
||||
| Screen Key | `ec.key.screen` | 11 | | 32 | Implemented |
|
||||
| Unmult | `ec.key.unmult` | 6 | | 32 | Implemented |
|
||||
|
|
@ -255,8 +255,8 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
|---|---|---:|:-:|:-:|---|
|
||||
| Matte Choker | `ec.matte.mattechoker` | 7 | | 32 | Implemented |
|
||||
| Mocha shape | `ec.obsolete.mochashape` | 6 | | 32 | Implemented |
|
||||
| Refine Hard Matte | `ec.matte.refinehard` | 11 | | 32 | Partial: no Reduce Chatter or motion blur |
|
||||
| Refine Soft Matte | `ec.matte.refinesoft` | 13 | | 32 | Partial: no Reduce Chatter or motion blur |
|
||||
| Refine Hard Matte | `ec.matte.refinehard` | 16 | | 32 | Implemented |
|
||||
| Refine Soft Matte | `ec.matte.refinesoft` | 18 | | 32 | Implemented |
|
||||
| Roto Brush & Refine Edge | `ec.matte.rotobrush` | 24 | | 32 | Implemented |
|
||||
| Simple Choker | `ec.matte.simplechoker` | 2 | | 32 | Implemented |
|
||||
|
||||
|
|
@ -264,18 +264,18 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
|
||||
| Effect | Id | Params | GPU | 32 | Status |
|
||||
|---|---|---:|:-:|:-:|---|
|
||||
| Add Grain | `ec.noise.addgrain` | 22 | GPU | 32 | Partial: no preview region, presets, channel balance or temporal controls |
|
||||
| Add Grain | `ec.noise.addgrain` | 32 | GPU | 32 | Implemented |
|
||||
| Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented |
|
||||
| Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented |
|
||||
| Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented |
|
||||
| Match Grain | `ec.noise.matchgrain` | 25 | | 32 | Partial: no preview region, presets, sampling or temporal controls |
|
||||
| Match Grain | `ec.noise.matchgrain` | 37 | | 32 | Implemented |
|
||||
| Median | `ec.noise.median` | 2 | | 32 | Implemented |
|
||||
| Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented |
|
||||
| Noise | `ec.noise.noise` | 3 | GPU | 32 | Implemented |
|
||||
| Noise Alpha | `ec.noise.noisealpha` | 8 | | 32 | Implemented |
|
||||
| Noise HLS | `ec.noise.noisehls` | 6 | | 32 | Implemented |
|
||||
| Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | | 32 | Implemented |
|
||||
| Remove Grain | `ec.noise.removegrain` | 7 | | 32 | Partial: no preview region, sampling or temporal filtering |
|
||||
| Remove Grain | `ec.noise.removegrain` | 20 | | 32 | Implemented |
|
||||
| Turbulent Noise | `ec.noise.turbulent` | 22 | | 32 | Implemented |
|
||||
|
||||
## Obsolete
|
||||
|
|
@ -288,7 +288,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| Gaussian Blur (Legacy) | `ec.obsolete.gaussianlegacy` | 2 | | 32 | Implemented |
|
||||
| Lightning | `ec.obsolete.lightning` | 25 | | 32 | Implemented |
|
||||
| Luma Key | `ec.key.luma` | 5 | | 32 | Implemented |
|
||||
| Path Text | `ec.obsolete.pathtext` | 21 | | 32 | Partial: no kerning, shear/scale, line spacing, fade time or jitter |
|
||||
| Path Text | `ec.obsolete.pathtext` | 32 | | 32 | Implemented |
|
||||
| Reduce Interlace Flicker | `ec.blur.reduceflicker` | 1 | | 32 | Implemented |
|
||||
| Spill Suppressor | `ec.key.spill` | 3 | | 32 | Implemented |
|
||||
|
||||
|
|
@ -329,12 +329,12 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| CC Scatterize | `ec.sim.ccscatterize` | 4 | | 32 | Implemented |
|
||||
| CC Snowfall | `ec.sim.ccsnowfall` | 19 | | 32 | Implemented |
|
||||
| CC Star Burst | `ec.sim.ccstarburst` | 6 | | 32 | Implemented |
|
||||
| Card Dance | `ec.sim.carddance` | 35 | | 32 | Partial: no camera system, corner pins or lighting/material controls |
|
||||
| Caustics | `ec.sim.caustics` | 21 | | 32 | Partial: no Sky group or light type |
|
||||
| Foam | `ec.sim.foam` | 24 | | 32 | Partial: no wobble, pop velocity, custom bubble texture, environment map or flow map |
|
||||
| Particle Playground | `ec.sim.particleplayground` | 37 | | 32 | Partial: no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles |
|
||||
| Shatter | `ec.sim.shatter` | 26 | | 32 | Partial: no custom shatter map, gradient, textures or extrusion rendering |
|
||||
| Wave World | `ec.sim.waveworld` | 27 | | 32 | Partial: no wireframe controls, dry-area rendering or ground group |
|
||||
| Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented |
|
||||
| Caustics | `ec.sim.caustics` | 28 | | 32 | Implemented |
|
||||
| Foam | `ec.sim.foam` | 36 | | 32 | Implemented |
|
||||
| Particle Playground | `ec.sim.particleplayground` | 92 | | 32 | Implemented |
|
||||
| Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented |
|
||||
| Wave World | `ec.sim.waveworld` | 35 | | 32 | Implemented |
|
||||
|
||||
## Stylize
|
||||
|
||||
|
|
@ -352,11 +352,11 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| CC Threshold | `ec.stylize.ccthreshold` | 4 | | 32 | Implemented |
|
||||
| CC Threshold RGB | `ec.stylize.ccthresholdrgb` | 7 | | 32 | Implemented |
|
||||
| CC Vignette | `ec.stylize.ccvignette` | 4 | | 32 | Implemented |
|
||||
| Cartoon | `ec.stylize.cartoon` | 11 | | 32 | Partial: no Edge Enhancement |
|
||||
| Cartoon | `ec.stylize.cartoon` | 12 | | 32 | Implemented |
|
||||
| Color Emboss | `ec.stylize.coloremboss` | 4 | | 32 | Implemented |
|
||||
| Emboss | `ec.stylize.emboss` | 4 | GPU | 32 | Implemented |
|
||||
| Find Edges | `ec.stylize.findedges` | 2 | GPU | 32 | Implemented |
|
||||
| Glow | `ec.stylize.glow` | 13 | GPU | 32 | Partial: no Glow Operation or arbitrary colour map |
|
||||
| Glow | `ec.stylize.glow` | 14 | GPU | 32 | Implemented |
|
||||
| Mosaic | `ec.stylize.mosaic` | 3 | GPU | 32 | Implemented |
|
||||
| Motion Tile | `ec.stylize.motiontile` | 8 | | 32 | Implemented |
|
||||
| Posterize | `ec.stylize.posterize` | 1 | | 32 | Implemented |
|
||||
|
|
@ -384,13 +384,13 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| Posterize Time | `ec.time.posterizetime` | 1 | | 32 | Implemented |
|
||||
| Time Difference | `ec.time.timedifference` | 5 | | 32 | Implemented |
|
||||
| Time Displacement | `ec.time.timedisplacement` | 4 | | 32 | Implemented |
|
||||
| Timewarp | `ec.time.timewarp` | 10 | | 32 | Partial: Pixel Motion blends frames (no optical flow); no matte, warp or crop controls |
|
||||
| Timewarp | `ec.time.timewarp` | 28 | | 32 | Implemented |
|
||||
|
||||
## Transition
|
||||
|
||||
| Effect | Id | Params | GPU | 32 | Status |
|
||||
|---|---|---:|:-:|:-:|---|
|
||||
| Block Dissolve | `ec.transition.blockdissolve` | 4 | | 32 | Partial: no Soft Edges option |
|
||||
| Block Dissolve | `ec.transition.blockdissolve` | 5 | | 32 | Implemented |
|
||||
| CC Glass Wipe | `ec.transition.ccglasswipe` | 5 | | 32 | Implemented |
|
||||
| CC Grid Wipe | `ec.transition.ccgridwipe` | 7 | | 32 | Implemented |
|
||||
| CC Image Wipe | `ec.transition.ccimagewipe` | 7 | | 32 | Implemented |
|
||||
|
|
@ -401,7 +401,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | | 32 | Implemented |
|
||||
| CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented |
|
||||
| CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented |
|
||||
| Card Wipe | `ec.transition.cardwipe` | 12 | | 32 | Partial: 2D flip only; no back layer picker, camera, lighting or jitter |
|
||||
| Card Wipe | `ec.transition.cardwipe` | 44 | | 32 | Implemented |
|
||||
| Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented |
|
||||
| Iris Wipe | `ec.transition.iriswipe` | 7 | | 32 | Implemented |
|
||||
| Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented |
|
||||
|
|
|
|||
Loading…
Reference in New Issue