mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M9.13: Lumetri (HSL Secondary, hue/sat curves, RGB curves, HDR, looks) + Hue/Sat ranges + Levels/Curves channels + Timewarp optical flow
- Lumetri: HSL Secondary key/refine/correction with Show Mask, Hue vs Sat/Hue/Luma, Luma vs Sat, Sat vs Sat and per-channel RGB curves, High Dynamic Range (HDR White / Specular), Input LUT and Look (custom .cube + built-in looks). - Hue/Saturation: Channel Control with Reds..Magentas ranges (range + fall-offs). - Levels: Channel popup and Red/Green/Blue/Alpha controls; Curves: Channel parameter. - catalog::param_shown: Effect Controls shows only what a popup selects. - Timewarp Pixel Motion: block-matching flow (raster::flow::block_flow_window) with Vector Detail, Smoothing, Error Threshold, Block Size, Weighting, Correct Luminance, Filtering, Build From One Image; Matte Layer/Channel, Warp Layer, Show, Source Crops. - GPU falls back to CPU when channel/range controls are used. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e3ae230387
commit
ce6db35d14
10 files changed
+1366
-88
No files matched your search
@@ -12,11 +12,7 @@ pub const PARTIAL: &[(&str, &str)] = &[
|
||||
("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"),
|
||||
@@ -39,7 +35,6 @@ pub const PARTIAL: &[(&str, &str)] = &[
|
||||
("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"),
|
||||
@@ -52,6 +47,45 @@ pub const PARTIAL: &[(&str, &str)] = &[
|
||||
("ec.transition.blockdissolve", "no Soft Edges option"),
|
||||
];
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = b("");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementation status of effect `id` (`Implemented` or `Partial: …`).
|
||||
pub fn status(id: &str) -> String {
|
||||
match PARTIAL.iter().find(|(e, _)| *e == id) {
|
||||
|
||||
@@ -911,7 +911,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),
|
||||
|
||||
+501
-10
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
+257
-25
@@ -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] {
|
||||
@@ -457,6 +604,24 @@ fn colorama(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
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 +666,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 +708,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(
|
||||
@@ -837,4 +1023,50 @@ 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));
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ pub mod warp_stab;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use color_fx::{exposure_settings, levels_clip};
|
||||
pub use color_fx::{HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip};
|
||||
pub use color2::Curve;
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::Ids;
|
||||
@@ -504,6 +504,12 @@ 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"),
|
||||
@@ -530,6 +536,9 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
||||
// Time: Timewarp.
|
||||
("tuning", "Tuning"),
|
||||
("motionBlur", "Motion Blur"),
|
||||
("smoothing", "Smoothing"),
|
||||
("weighting", "Weighting"),
|
||||
("sourceCrops", "Source Crops"),
|
||||
// Simulation.
|
||||
("extras", "Extras"),
|
||||
("wiggle", "Wiggle"),
|
||||
|
||||
+454
-21
@@ -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 };
|
||||
|
||||
@@ -307,11 +307,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];
|
||||
|
||||
+21
-10
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+7
-7
@@ -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 303 effects with 2434 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 303 effects with 2562 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**: 24 effects also run on the GPU compositor with identical results.
|
||||
- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||
- **Status**: 261 implemented in full, 42 partial (what is missing is listed).
|
||||
- **Status**: 266 implemented in full, 37 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`).
|
||||
|
||||
@@ -103,15 +103,15 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
||||
| Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented |
|
||||
| Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented |
|
||||
| Colorama | `ec.color.colorama` | 5 | | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking |
|
||||
| Curves | `ec.color.curves` | 0 | GPU | 32 | Partial: no Channel popup |
|
||||
| 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 | | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files |
|
||||
| Lumetri Color | `ec.color.lumetri` | 66 | | 32 | Implemented |
|
||||
| OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented |
|
||||
| OCIO Color Space Transform | `ec.color.ociocolorspace` | 4 | | 32 | Implemented |
|
||||
| OCIO Display Transform | `ec.color.ociodisplay` | 5 | | 32 | Implemented |
|
||||
@@ -383,7 +383,7 @@ 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
|
||||
|
||||
|
||||
Reference in new issue
Block a user