Files
filmcraft/crates/render/src/effects.rs
T
Brandon ThomasandClaude Opus 5.5 b141c406e9 M10.1: text engine (crates/text): font database with bundled OFL fonts and system-font discovery, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache and rasteriser; captions and Timecode/Clip Name burn-ins use it
- New L1 crate filmcraft-text: fonts (bundled Inter Regular/Medium/SemiBold/Bold/Italic,
  JetBrains Mono, Noto Serif; DirectorySource scans system font folders reading only name/OS2
  tables; family/style resolution with synthetic bold/italic; per-char fallback), layout
  (harfrust kerning/ligatures, unicode-bidi, unicode-linebreak, alignment incl. justify,
  leading, tracking, baseline shift, all/small caps, underline, carets/hit-test/selection,
  cached), raster (moved from captions; active-edge list, shapes, emboldening), render
  (outline + sub-pixel glyph-mask caches; transformed-outline path for rotation/skew),
  mask (distance-transform strokes, blur).
- Captions burn-in now shapes and kerns via the engine (font.rs/raster.rs removed).
- Timecode / Clip Name effects draw real text (full clip names, not seven-segment digits);
  text_burnin golden re-blessed.
- UI takes its font bytes from the text crate (no duplicate copies in the binary).
- New fonts: Inter Bold, Inter Italic (OFL-1.1, rsms/inter v4.1), Noto Serif Regular
  (OFL-1.1, notofonts) with sidecars, ATTRIBUTION.md rows and licence text.
- Perf: 3-line 96 px title at 1080p ~1.6 ms cold / 0.15 ms warm (release).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 01:22:30 -04:00

1399 lines
57 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! CPU reference implementations of the video effects in `filmcraft_project::effect`.
//!
//! Effects operate in place on a layer [`Image`] (premultiplied linear f32). Spatial parameters are
//! authored in full-resolution clip pixels; `FxCtx::px_scale` converts them to the working image
//! (so ½/¼ playback resolution renders the same look). Colour-grading math runs on display-encoded
//! straight colour where artists expect it (contrast, levels, posterize), linear light elsewhere.
use filmcraft_color::{hsl_to_rgb, linear_to_srgb, luma709, rgb_to_hsl, srgb_to_linear};
use filmcraft_geom::{Affine, Vec2};
use filmcraft_project::{EffectInstance, ParamValue};
use filmcraft_time::Tick;
use rayon::prelude::*;
use crate::image::Image;
/// Context for evaluating an effect at a time.
pub struct FxCtx<'a> {
/// Media time used to evaluate keyframes.
pub t: Tick,
/// Working-image pixels per full-resolution clip pixel.
pub px_scale: f32,
/// Seconds since the clip start (for animated generators such as Noise/Strobe).
pub seconds: f64,
/// Formatted sequence timecode (for the Timecode effect).
pub timecode: &'a str,
pub clip_name: &'a str,
}
fn f(e: &EffectInstance, id: &str, cx: &FxCtx) -> f32 {
e.f64_at(id, cx.t) as f32
}
fn b(e: &EffectInstance, id: &str) -> bool {
e.param(id).and_then(|p| p.value.as_bool()).unwrap_or(false)
}
fn choice(e: &EffectInstance, id: &str) -> u32 {
match e.param(id).map(|p| &p.value) {
Some(ParamValue::Choice(c)) => *c,
_ => 0,
}
}
fn color(e: &EffectInstance, id: &str, cx: &FxCtx) -> [f32; 4] {
e.param(id).and_then(|p| p.value_at(cx.t).as_color()).unwrap_or([1.0; 4])
}
/// A point param in working-image pixels; NaN components default to the image centre.
fn point(e: &EffectInstance, id: &str, cx: &FxCtx, img: &Image) -> Vec2 {
let v = e.param(id).map(|p| p.vec2_at(cx.t)).unwrap_or(Vec2::new(f64::NAN, f64::NAN));
Vec2::new(
if v.x.is_nan() { img.w as f64 / 2.0 } else { v.x * cx.px_scale as f64 },
if v.y.is_nan() { img.h as f64 / 2.0 } else { v.y * cx.px_scale as f64 },
)
}
#[inline]
fn enc(c: [f32; 3]) -> [f32; 3] {
[linear_to_srgb(c[0].max(0.0)), linear_to_srgb(c[1].max(0.0)), linear_to_srgb(c[2].max(0.0))]
}
#[inline]
fn dec(c: [f32; 3]) -> [f32; 3] {
[srgb_to_linear(c[0].clamp(0.0, 1.0)), srgb_to_linear(c[1].clamp(0.0, 1.0)), srgb_to_linear(c[2].clamp(0.0, 1.0))]
}
fn hash3(x: usize, y: usize, z: u64) -> f32 {
let mut h = (x as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (y as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F) ^ z.wrapping_mul(0x1656_67B1_9E37_79F9);
h ^= h >> 31;
h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
h ^= h >> 29;
(h >> 40) as f32 / (1u64 << 24) as f32
}
/// Apply one effect. Unknown/unimplemented ids are a no-op (they still round-trip in the project).
pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
if !e.enabled || img.w == 0 || img.h == 0 {
return;
}
match e.effect.as_str() {
"brightness_contrast" => {
let br = f(e, "brightness", cx) / 100.0 * 0.4;
let co = 1.0 + f(e, "contrast", cx) / 100.0;
img.map_rgb(|c, _, _| {
let c = enc(c);
dec(c.map(|v| (v - 0.5) * co + 0.5 + br))
});
}
"proc_amp" => {
let br = f(e, "brightness", cx) / 100.0 * 0.4;
let co = f(e, "contrast", cx) / 100.0;
let hue = f(e, "hue", cx) / 360.0;
let sat = f(e, "saturation", cx) / 100.0;
img.map_rgb(|c, _, _| {
let c = enc(c);
let mut hsl = rgb_to_hsl(c[0], c[1], c[2]);
hsl[0] = (hsl[0] + hue).rem_euclid(1.0);
hsl[1] = (hsl[1] * sat).clamp(0.0, 1.0);
let c = hsl_to_rgb(hsl[0], hsl[1], hsl[2]);
dec(c.map(|v| (v - 0.5) * co + 0.5 + br))
});
}
"tint" => {
let bl = color(e, "black", cx);
let wh = color(e, "white", cx);
let amt = f(e, "amount", cx) / 100.0;
img.map_rgb(|c, _, _| {
let l = linear_to_srgb(luma709(c[0], c[1], c[2]).max(0.0));
let t = [bl[0] + (wh[0] - bl[0]) * l, bl[1] + (wh[1] - bl[1]) * l, bl[2] + (wh[2] - bl[2]) * l];
let e = enc(c);
dec([e[0] + (t[0] - e[0]) * amt, e[1] + (t[1] - e[1]) * amt, e[2] + (t[2] - e[2]) * amt])
});
}
"black_white" => img.map_rgb(|c, _, _| {
let l = luma709(c[0], c[1], c[2]);
[l, l, l]
}),
"color_balance" => {
let g = |k: &str| f(e, k, cx) / 100.0 * 0.25;
let sh = [g("shadow_r"), g("shadow_g"), g("shadow_b")];
let md = [g("mid_r"), g("mid_g"), g("mid_b")];
let hi = [g("hi_r"), g("hi_g"), g("hi_b")];
let preserve = b(e, "preserve");
img.map_rgb(|c, _, _| {
let c = enc(c);
let l = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
let ws = (1.0 - l).powi(2);
let wh = l.powi(2);
let wm = 1.0 - ws - wh;
let mut o = [0.0; 3];
for k in 0..3 {
o[k] = c[k] + sh[k] * ws + md[k] * wm.max(0.0) + hi[k] * wh;
}
if preserve {
let l2 = 0.2126 * o[0] + 0.7152 * o[1] + 0.0722 * o[2];
let d = l - l2;
o = o.map(|v| v + d);
}
dec(o)
});
}
"leave_color" => {
let amt = f(e, "amount", cx) / 100.0;
let key = color(e, "color", cx);
let tol = f(e, "tolerance", cx) / 100.0;
let soft = f(e, "softness", cx) / 100.0 + 1e-4;
let kh = rgb_to_hsl(key[0], key[1], key[2])[0];
img.map_rgb(|c, _, _| {
let ec = enc(c);
let h = rgb_to_hsl(ec[0], ec[1], ec[2])[0];
let d = (h - kh).abs().min(1.0 - (h - kh).abs()) * 2.0;
let keep = 1.0 - ((d - tol) / soft).clamp(0.0, 1.0);
let l = luma709(c[0], c[1], c[2]);
let k = amt * (1.0 - keep);
[c[0] + (l - c[0]) * k, c[1] + (l - c[1]) * k, c[2] + (l - c[2]) * k]
});
}
"change_to_color" => {
let from = color(e, "from", cx);
let to = color(e, "to", cx);
let tol = f(e, "hue_tol", cx) / 100.0;
let soft = f(e, "softness", cx) / 100.0 * 0.3 + 1e-4;
let fh = rgb_to_hsl(from[0], from[1], from[2])[0];
let th = rgb_to_hsl(to[0], to[1], to[2])[0];
img.map_rgb(|c, _, _| {
let ec = enc(c);
let mut hsl = rgb_to_hsl(ec[0], ec[1], ec[2]);
let d = (hsl[0] - fh).abs().min(1.0 - (hsl[0] - fh).abs());
let w = 1.0 - ((d - tol) / soft).clamp(0.0, 1.0);
hsl[0] = (hsl[0] + (th - fh) * w).rem_euclid(1.0);
dec(hsl_to_rgb(hsl[0], hsl[1], hsl[2]))
});
}
"color_pass" => {
let key = color(e, "color", cx);
let sim = f(e, "similarity", cx) / 100.0;
let rev = b(e, "reverse");
img.map_rgb(|c, _, _| {
let ec = enc(c);
let d = ((ec[0] - key[0]).powi(2) + (ec[1] - key[1]).powi(2) + (ec[2] - key[2]).powi(2)).sqrt();
let pass = (d <= sim * 1.2) != rev;
if pass {
c
} else {
let l = luma709(c[0], c[1], c[2]);
[l, l, l]
}
});
}
"gamma_correction" => {
let g = f(e, "gamma", cx) / 10.0;
img.map_rgb(|c, _, _| dec(enc(c).map(|v| v.max(0.0).powf(g))));
}
"levels" => {
let ib = f(e, "in_black", cx) / 255.0;
let iw = (f(e, "in_white", cx) / 255.0).max(ib + 1e-3);
let ob = f(e, "out_black", cx) / 255.0;
let ow = f(e, "out_white", cx) / 255.0;
let g = 100.0 / f(e, "gamma", cx).max(1.0);
img.map_rgb(|c, _, _| dec(enc(c).map(|v| ob + (((v - ib) / (iw - ib)).clamp(0.0, 1.0)).powf(g) * (ow - ob))));
}
"extract" => {
let lo = f(e, "black", cx) / 255.0;
let hi = f(e, "white", cx) / 255.0;
let soft = f(e, "softness", cx) / 100.0 * 0.2 + 1e-4;
let inv = b(e, "invert");
img.map_rgb(|c, _, _| {
let l = linear_to_srgb(luma709(c[0], c[1], c[2]).max(0.0));
let inside = ((l - lo) / soft).clamp(0.0, 1.0).min(((hi - l) / soft).clamp(0.0, 1.0));
let v = if inv { 1.0 - inside } else { inside };
[v, v, v]
});
}
"invert" => {
let ch = choice(e, "channel");
let blend = f(e, "blend", cx) / 100.0;
if ch == 4 {
img.px.par_chunks_mut(4).for_each(|p| {
let a = p[3];
let na = 1.0 - a;
let k = if a > 1e-6 { na / a } else { 0.0 };
for c in &mut p[..3] {
*c *= k;
}
p[3] = na * (1.0 - blend) + a * blend;
});
} else {
img.map_rgb(|c, _, _| {
let ec = enc(c);
let mut o = ec;
for k in 0..3 {
if ch == 0 || ch as usize == k + 1 {
o[k] = 1.0 - ec[k];
}
}
dec([o[0] + (ec[0] - o[0]) * blend, o[1] + (ec[1] - o[1]) * blend, o[2] + (ec[2] - o[2]) * blend])
});
}
}
"posterize" => {
let n = f(e, "levels", cx).max(2.0) - 1.0;
img.map_rgb(|c, _, _| dec(enc(c).map(|v| (v * n).round() / n)));
}
"lumetri" => lumetri(img, e, cx),
"gaussian_blur" => {
let r = f(e, "blurriness", cx) * cx.px_scale * 0.5;
let dims = choice(e, "dimensions");
let repeat = b(e, "repeat_edge");
gaussian(img, if dims == 2 { 0.0 } else { r }, if dims == 1 { 0.0 } else { r }, repeat);
}
"camera_blur" => {
let r = f(e, "percent", cx) * cx.px_scale * 0.3;
gaussian(img, r, r, true);
}
"directional_blur" => {
let len = f(e, "length", cx) * cx.px_scale * 2.0;
let dir = (f(e, "direction", cx) as f64).to_radians();
directional_blur(img, len, dir);
}
"sharpen" => {
let amt = f(e, "amount", cx) / 100.0;
unsharp(img, 1.0 * cx.px_scale.max(0.35), amt, 0.0);
}
"unsharp_mask" => {
let amt = f(e, "amount", cx) / 100.0;
let r = f(e, "radius", cx) * cx.px_scale;
let th = f(e, "threshold", cx) / 255.0;
unsharp(img, r, amt, th);
}
"median" => {
let r = (f(e, "radius", cx) * cx.px_scale).round().clamp(0.0, 4.0) as isize;
if r > 0 {
median(img, r);
}
}
"noise" => {
let amt = f(e, "amount", cx) / 100.0;
let colored = b(e, "color");
let clip = b(e, "clip");
let frame = (cx.seconds * 30.0) as u64;
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let p = &mut row[x * 4..x * 4 + 4];
let a = p[3];
if a <= 0.0 {
continue;
}
let n0 = hash3(x, y, frame) - 0.5;
let ns = if colored { [n0, hash3(x, y, frame + 7777) - 0.5, hash3(x, y, frame + 99_991) - 0.5] } else { [n0; 3] };
let c = enc([p[0] / a, p[1] / a, p[2] / a]);
let mut o = [c[0] + ns[0] * amt, c[1] + ns[1] * amt, c[2] + ns[2] * amt];
if clip {
o = o.map(|v| v.clamp(0.0, 1.0));
}
let o = dec(o);
p[0] = o[0] * a;
p[1] = o[1] * a;
p[2] = o[2] * a;
}
});
}
"mosaic" => {
let bx = f(e, "horizontal", cx).max(1.0) as usize;
let by = f(e, "vertical", cx).max(1.0) as usize;
mosaic(img, bx, by);
}
"find_edges" => {
let inv = b(e, "invert");
let blend = f(e, "blend", cx) / 100.0;
let src = img.clone();
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let l = |dx: isize, dy: isize| {
let p = src.get_clamped(x as isize + dx, y as isize + dy);
luma709(p[0], p[1], p[2])
};
let gx = l(1, -1) + 2.0 * l(1, 0) + l(1, 1) - l(-1, -1) - 2.0 * l(-1, 0) - l(-1, 1);
let gy = l(-1, 1) + 2.0 * l(0, 1) + l(1, 1) - l(-1, -1) - 2.0 * l(0, -1) - l(1, -1);
let mut m = (gx * gx + gy * gy).sqrt().min(1.0);
if !inv {
m = 1.0 - m;
}
let o = src.get(x, y);
let a = o[3];
for k in 0..3 {
row[x * 4 + k] = m * a * (1.0 - blend) + o[k] * blend;
}
}
});
}
"emboss" => {
let dir = (f(e, "direction", cx) as f64).to_radians();
let relief = f(e, "relief", cx) * cx.px_scale.max(0.25);
let contrast = f(e, "contrast", cx) / 100.0;
let blend = f(e, "blend", cx) / 100.0;
let (dx, dy) = ((dir.cos() as f32) * relief, (-dir.sin() as f32) * relief);
let src = img.clone();
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let a = src.sample_bilinear_clamped(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
let bq = src.sample_bilinear_clamped(x as f32 + 0.5 - dx, y as f32 + 0.5 - dy);
let v = srgb_to_linear((0.5 + (luma709(a[0], a[1], a[2]) - luma709(bq[0], bq[1], bq[2])) * contrast * 2.0).clamp(0.0, 1.0));
let o = src.get(x, y);
for k in 0..3 {
row[x * 4 + k] = v * o[3] * (1.0 - blend) + o[k] * blend;
}
}
});
}
"replicate" => {
let n = f(e, "count", cx).clamp(1.0, 16.0) as usize;
let src = img.clone();
let (w, h) = (img.w, img.h);
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let u = (x * n) % w;
let v = (y * n) % h;
let s = src.sample_bilinear_clamped((u as f32 + 0.5) + (n as f32 - 1.0) * 0.5 / n as f32, v as f32 + 0.5);
row[x * 4..x * 4 + 4].copy_from_slice(&s);
}
});
}
"strobe" => {
let period = f(e, "period", cx).max(0.001) as f64;
let dur = f(e, "duration", cx) as f64;
if (cx.seconds % period) < dur {
let c = color(e, "color", cx);
let blend = f(e, "blend", cx) / 100.0;
let lc = dec([c[0], c[1], c[2]]);
img.map_rgb(|o, _, _| [lc[0] + (o[0] - lc[0]) * blend, lc[1] + (o[1] - lc[1]) * blend, lc[2] + (o[2] - lc[2]) * blend]);
}
}
"crop" => {
let l = f(e, "left", cx) / 100.0;
let t = f(e, "top", cx) / 100.0;
let r = f(e, "right", cx) / 100.0;
let bt = f(e, "bottom", cx) / 100.0;
let feather = f(e, "feather", cx) * cx.px_scale;
if b(e, "zoom") && l + r < 0.99 && t + bt < 0.99 {
let src = img.clone();
let (w, h) = (img.w as f64, img.h as f64);
let m =
Affine::scale(1.0 / (1.0 - (l + r) as f64), 1.0 / (1.0 - (t + bt) as f64)).then_apply(&Affine::translate(-(l as f64) * w, -(t as f64) * h));
*img = src.transformed(img.w, img.h, &m);
} else {
crop(img, l, t, r, bt, feather);
}
}
"horizontal_flip" => {
let w = img.w;
img.px.par_chunks_mut(w * 4).for_each(|row| {
for x in 0..w / 2 {
for k in 0..4 {
row.swap(x * 4 + k, (w - 1 - x) * 4 + k);
}
}
});
}
"vertical_flip" => {
let (w, h) = (img.w, img.h);
for y in 0..h / 2 {
let (a, bb) = img.px.split_at_mut((h - 1 - y) * w * 4);
a[y * w * 4..(y + 1) * w * 4].swap_with_slice(&mut bb[..w * 4]);
}
}
"edge_feather" => {
let amt = f(e, "amount", cx) / 100.0 * (img.w.min(img.h) as f32) * 0.5;
crop(img, 0.0, 0.0, 0.0, 0.0, amt);
}
"transform" => {
let anchor = point(e, "anchor", cx, img);
let pos = point(e, "position", cx, img);
let sh = f(e, "scale_height", cx) as f64 / 100.0;
let sw = if b(e, "uniform_scale") { sh } else { f(e, "scale_width", cx) as f64 / 100.0 };
let rot = f(e, "rotation", cx) as f64;
let skew = (f(e, "skew", cx) as f64).to_radians().tan();
let skew_axis = f(e, "skew_axis", cx) as f64;
let op = f(e, "opacity", cx) / 100.0;
let sk = Affine::rotate_deg(skew_axis)
.then_apply(&Affine { a: 1.0, b: 0.0, c: skew, d: 1.0, e: 0.0, f: 0.0 })
.then_apply(&Affine::rotate_deg(-skew_axis));
let m = Affine::translate(pos.x, pos.y)
.then_apply(&Affine::rotate_deg(rot))
.then_apply(&sk)
.then_apply(&Affine::scale(sw, sh))
.then_apply(&Affine::translate(-anchor.x, -anchor.y));
let mut out = img.transformed(img.w, img.h, &m);
out.scale_alpha(op);
*img = out;
}
"mirror" => {
let c = point(e, "center", cx, img);
let ang = (f(e, "angle", cx) as f64).to_radians();
let (nx, ny) = (ang.cos(), ang.sin());
let src = img.clone();
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let d = (px - c.x) * nx + (py - c.y) * ny;
if d > 0.0 {
let (rx, ry) = (px - 2.0 * d * nx, py - 2.0 * d * ny);
row[x * 4..x * 4 + 4].copy_from_slice(&src.sample_bilinear(rx as f32, ry as f32));
}
}
});
}
"offset" => {
let s = point(e, "shift", cx, img);
let (dx, dy) = (s.x - img.w as f64 / 2.0, s.y - img.h as f64 / 2.0);
let blend = f(e, "blend", cx) / 100.0;
let src = img.clone();
let (w, h) = (img.w as f64, img.h as f64);
let wi = img.w;
img.px.par_chunks_mut(wi * 4).enumerate().for_each(|(y, row)| {
for x in 0..wi {
let u = (x as f64 + 0.5 - dx).rem_euclid(w);
let v = (y as f64 + 0.5 - dy).rem_euclid(h);
let p = src.sample_bilinear_clamped(u as f32, v as f32);
let o = src.get(x, y);
for k in 0..4 {
row[x * 4 + k] = p[k] + (o[k] - p[k]) * blend;
}
}
});
}
"twirl" => {
let c = point(e, "center", cx, img);
let ang = (f(e, "angle", cx) as f64).to_radians();
let rad = f(e, "radius", cx) as f64 / 100.0 * (img.w.min(img.h) as f64);
warp(img, |x, y| {
let (dx, dy) = (x - c.x, y - c.y);
let d = (dx * dx + dy * dy).sqrt();
if d >= rad {
return (x, y);
}
let k = 1.0 - d / rad;
let a = ang * k * k;
let (s, co) = a.sin_cos();
(c.x + dx * co - dy * s, c.y + dx * s + dy * co)
});
}
"wave_warp" => {
let hgt = f(e, "height", cx) as f64 * cx.px_scale as f64;
let wid = (f(e, "width", cx) as f64 * cx.px_scale as f64).max(1.0);
let dir = (f(e, "direction", cx) as f64).to_radians();
let phase = cx.seconds * f(e, "speed", cx) as f64 * std::f64::consts::TAU;
let (ux, uy) = (dir.sin(), -dir.cos());
warp(img, |x, y| {
let along = x * ux + y * uy;
let off = hgt * (along / wid * std::f64::consts::TAU - phase).sin();
(x - uy * off, y + ux * off)
});
}
"lens_distortion" => {
let k = f(e, "curvature", cx) as f64 / 100.0;
let (cxp, cyp) = (
img.w as f64 / 2.0 + f(e, "h_decentering", cx) as f64 / 100.0 * img.w as f64 / 2.0,
img.h as f64 / 2.0 + f(e, "v_decentering", cx) as f64 / 100.0 * img.h as f64 / 2.0,
);
let norm = (img.w as f64 / 2.0).hypot(img.h as f64 / 2.0);
warp(img, |x, y| {
let (dx, dy) = ((x - cxp) / norm, (y - cyp) / norm);
let r2 = dx * dx + dy * dy;
let s = 1.0 - k * r2;
(cxp + dx * s * norm, cyp + dy * s * norm)
});
}
"basic_3d" => {
let sw = (f(e, "swivel", cx) as f64).to_radians();
let tl = (f(e, "tilt", cx) as f64).to_radians();
let dist = f(e, "distance", cx) as f64;
let (w, h) = (img.w as f64, img.h as f64);
let focal = w.max(h) * 1.2;
// inverse perspective mapping: ray through (x,y) intersected with rotated plane
warp_opt(img, |x, y| {
let (px, py) = (x - w / 2.0, y - h / 2.0);
let (ss, cs) = sw.sin_cos();
let (st, ct) = tl.sin_cos();
// plane normal after rotation (Ry(swivel) * Rx(tilt)) of (0,0,1)
let n = [ss * ct, -st, cs * ct];
let u = [cs, 0.0, -ss];
let v = [ss * st, ct, cs * st];
let z0 = focal + dist * 10.0;
let dir = [px, py, focal];
let denom = n[0] * dir[0] + n[1] * dir[1] + n[2] * dir[2];
if denom.abs() < 1e-9 {
return None;
}
let tt = (n[2] * z0) / denom;
if tt <= 0.0 {
return None;
}
let hit = [dir[0] * tt, dir[1] * tt, dir[2] * tt - z0];
let su = hit[0] * u[0] + hit[1] * u[1] + hit[2] * u[2];
let sv = hit[0] * v[0] + hit[1] * v[1] + hit[2] * v[2];
Some((su + w / 2.0, sv + h / 2.0))
});
}
"drop_shadow" => {
let c = color(e, "color", cx);
let op = f(e, "opacity", cx) / 100.0;
let dir = (f(e, "direction", cx) as f64).to_radians();
let dist = f(e, "distance", cx) as f64 * cx.px_scale as f64;
let soft = f(e, "softness", cx) * cx.px_scale * 0.5;
let only = b(e, "only");
let (dx, dy) = (dir.sin() * dist, -dir.cos() * dist);
let lc = dec([c[0], c[1], c[2]]);
let mut sh = img.transformed(img.w, img.h, &Affine::translate(dx, dy));
sh.px.par_chunks_mut(4).for_each(|p| {
let a = p[3] * op;
p[0] = lc[0] * a;
p[1] = lc[1] * a;
p[2] = lc[2] * a;
p[3] = a;
});
if soft > 0.3 {
gaussian(&mut sh, soft, soft, false);
}
if !only {
crate::blend::composite(&mut sh, img, 1.0, crate::blend::Blend::Normal);
}
*img = sh;
}
"bevel_alpha" => {
let th = f(e, "thickness", cx) * cx.px_scale;
let ang = (f(e, "angle", cx) as f64).to_radians();
let inten = f(e, "intensity", cx) / 100.0;
let src = img.clone();
let (lx, ly) = (ang.cos() as f32, -ang.sin() as f32);
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let a = |dx: f32, dy: f32| src.sample_bilinear_clamped(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy)[3];
let gx = a(th, 0.0) - a(-th, 0.0);
let gy = a(0.0, th) - a(0.0, -th);
let shade = -(gx * lx + gy * ly) * inten;
let p = &mut row[x * 4..x * 4 + 4];
for k in 0..3 {
p[k] = (p[k] + shade * p[3]).clamp(0.0, p[3]);
}
}
});
}
"ultra_key" | "color_key" => key(img, e, cx),
"luma_key" => {
let th = f(e, "threshold", cx) / 100.0;
let cut = f(e, "cutoff", cx) / 100.0;
img.px.par_chunks_mut(4).for_each(|p| {
if p[3] <= 0.0 {
return;
}
let l = linear_to_srgb(luma709(p[0] / p[3], p[1] / p[3], p[2] / p[3]).max(0.0));
let a = if l <= cut {
0.0
} else if l >= th.max(cut + 1e-3) {
1.0
} else {
(l - cut) / (th - cut).max(1e-3)
};
for v in p.iter_mut() {
*v *= a;
}
});
}
"four_color_gradient" => {
let cs = [color(e, "c1", cx), color(e, "c2", cx), color(e, "c3", cx), color(e, "c4", cx)];
let op = f(e, "opacity", cx) / 100.0;
let (w, h) = (img.w as f32, img.h as f32);
let pts = [(0.25 * w, 0.25 * h), (0.75 * w, 0.25 * h), (0.25 * w, 0.75 * h), (0.75 * w, 0.75 * h)];
let blend_exp = 1.0 + f(e, "blend", cx) / 100.0;
fill_over(img, op, |x, y| {
let mut acc = [0.0f32; 3];
let mut wsum = 0.0;
for (i, p) in pts.iter().enumerate() {
let d = ((x - p.0).powi(2) + (y - p.1).powi(2)).sqrt().max(1.0);
let wgt = 1.0 / d.powf(blend_exp);
for k in 0..3 {
acc[k] += cs[i][k] * wgt;
}
wsum += wgt;
}
dec(acc.map(|v| v / wsum))
});
}
"ramp" => {
let s = point(e, "start", cx, img);
let en = point(e, "end", cx, img);
let sc = color(e, "start_color", cx);
let ec = color(e, "end_color", cx);
let radial = choice(e, "shape") == 1;
let blend = f(e, "blend", cx) / 100.0;
let (vx, vy) = (en.x - s.x, en.y - s.y);
let len2 = (vx * vx + vy * vy).max(1e-9);
fill_over(img, 1.0 - blend, |x, y| {
let t = if radial {
((x as f64 - s.x).hypot(y as f64 - s.y) / len2.sqrt()) as f32
} else {
(((x as f64 - s.x) * vx + (y as f64 - s.y) * vy) / len2) as f32
}
.clamp(0.0, 1.0);
dec([sc[0] + (ec[0] - sc[0]) * t, sc[1] + (ec[1] - sc[1]) * t, sc[2] + (ec[2] - sc[2]) * t])
});
}
"circle" => {
let c = point(e, "center", cx, img);
let r = f(e, "radius", cx) * cx.px_scale;
let col = color(e, "color", cx);
let op = f(e, "opacity", cx) / 100.0;
let lc = dec([col[0], col[1], col[2]]);
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let d = ((x as f64 + 0.5 - c.x).hypot(y as f64 + 0.5 - c.y)) as f32;
let a = (r - d + 0.5).clamp(0.0, 1.0) * op;
if a > 0.0 {
let p = &mut row[x * 4..x * 4 + 4];
for k in 0..3 {
p[k] = lc[k] * a + p[k] * (1.0 - a);
}
p[3] = a + p[3] * (1.0 - a);
}
}
});
}
"grid" => {
let size = (f(e, "size", cx) * cx.px_scale).max(1.0);
let border = f(e, "border", cx) * cx.px_scale;
let col = color(e, "color", cx);
let op = f(e, "opacity", cx) / 100.0;
let lc = dec([col[0], col[1], col[2]]);
let w = img.w;
let (ox, oy) = (img.w as f32 / 2.0, img.h as f32 / 2.0);
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let gx = ((x as f32 - ox).rem_euclid(size)).min(size - (x as f32 - ox).rem_euclid(size));
let gy = ((y as f32 - oy).rem_euclid(size)).min(size - (y as f32 - oy).rem_euclid(size));
let a = ((border * 0.5 - gx.min(gy) + 0.5).clamp(0.0, 1.0)) * op;
if a > 0.0 {
let p = &mut row[x * 4..x * 4 + 4];
for k in 0..3 {
p[k] = lc[k] * a + p[k] * (1.0 - a);
}
p[3] = a + p[3] * (1.0 - a);
}
}
});
}
"lens_flare" => {
let c = point(e, "center", cx, img);
let br = f(e, "brightness", cx) / 100.0;
let blend = f(e, "blend", cx) / 100.0;
let (w, h) = (img.w as f64, img.h as f64);
let (mx, my) = (w / 2.0, h / 2.0);
let scale = w.max(h);
let wi = img.w;
img.px.par_chunks_mut(wi * 4).enumerate().for_each(|(y, row)| {
for x in 0..wi {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let d = (px - c.x).hypot(py - c.y) / scale;
let mut add = [1.0f32, 0.9, 0.7].map(|k| k * (0.35 * (-d * 18.0).exp() + 0.08 * (-d * 3.0).exp()) as f32);
for (i, (t, rad, tint)) in [(0.5, 0.05, [0.4f32, 0.6, 1.0]), (1.3, 0.03, [1.0, 0.5, 0.3]), (1.7, 0.08, [0.4, 1.0, 0.5])].iter().enumerate()
{
let gx = c.x + (mx - c.x) * 2.0 * t / 2.0 * 2.0;
let gy = c.y + (my - c.y) * 2.0 * t / 2.0 * 2.0;
let dd = (px - gx).hypot(py - gy) / scale;
let ring = (1.0 - ((dd - rad) / 0.01).abs()).max(0.0) as f32 * 0.15 + if dd < *rad { 0.06 } else { 0.0 };
let _ = i;
for k in 0..3 {
add[k] += tint[k] * ring;
}
}
let p = &mut row[x * 4..x * 4 + 4];
for k in 0..3 {
p[k] += add[k] * br * (1.0 - blend) * p[3].max(0.0);
}
}
});
}
"timecode" | "clip_name" => {
let (text, family) = if e.effect == "timecode" { (cx.timecode.to_string(), "JetBrains Mono") } else { (cx.clip_name.to_string(), "Inter") };
let pos = point(e, "position", cx, img);
let px = (f(e, "size", cx) / 100.0 * img.h as f32 * 0.5).max(6.0);
let box_alpha = if e.effect == "timecode" { (f(e, "opacity", cx) / 100.0).clamp(0.0, 1.0) * 0.8 } else { 0.8 };
crate::graphics::burn_text(img, &text, family, pos, px, box_alpha);
}
_ => {}
}
}
/// Fill an opaque generated colour over the image at `op` opacity (Generate category).
fn fill_over(img: &mut Image, op: f32, f: impl Fn(f32, f32) -> [f32; 3] + Sync) {
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let c = f(x as f32 + 0.5, y as f32 + 0.5);
let p = &mut row[x * 4..x * 4 + 4];
for k in 0..3 {
p[k] = c[k] * op + p[k] * (1.0 - op);
}
p[3] = op + p[3] * (1.0 - op);
}
});
}
fn warp(img: &mut Image, f: impl Fn(f64, f64) -> (f64, f64) + Sync) {
warp_opt(img, |x, y| Some(f(x, y)));
}
fn warp_opt(img: &mut Image, f: impl Fn(f64, f64) -> Option<(f64, f64)> + Sync) {
let src = img.clone();
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let p = match f(x as f64 + 0.5, y as f64 + 0.5) {
Some((u, v)) => src.sample_bilinear(u as f32, v as f32),
None => [0.0; 4],
};
row[x * 4..x * 4 + 4].copy_from_slice(&p);
}
});
}
fn crop(img: &mut Image, l: f32, t: f32, r: f32, b: f32, feather: f32) {
let (w, h) = (img.w as f32, img.h as f32);
let (x0, x1, y0, y1) = (l * w, w * (1.0 - r), t * h, h * (1.0 - b));
let fe = feather.max(0.0);
let wi = img.w;
img.px.par_chunks_mut(wi * 4).enumerate().for_each(|(y, row)| {
let py = y as f32 + 0.5;
for x in 0..wi {
let px = x as f32 + 0.5;
let d = (px - x0).min(x1 - px).min(py - y0).min(y1 - py);
let a = if fe > 0.0 { (d / fe).clamp(0.0, 1.0) } else { (d + 0.5).clamp(0.0, 1.0) };
if a < 1.0 {
for v in &mut row[x * 4..x * 4 + 4] {
*v *= a;
}
}
}
});
}
fn key(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let ultra = e.effect == "ultra_key";
let kc = if ultra { color(e, "key_color", cx) } else { color(e, "color", cx) };
let kycc = filmcraft_color::rgb_to_ycbcr(kc[0], kc[1], kc[2], filmcraft_color::Matrix::Bt709);
let (tol, soft, spill, output) = if ultra {
let tol = 0.05 + f(e, "tolerance", cx) / 100.0 * 0.25 + f(e, "transparency", cx) / 100.0 * 0.05;
(tol, 0.05 + f(e, "soften", cx) / 100.0 * 0.2 + f(e, "pedestal", cx) / 100.0 * 0.05, f(e, "spill", cx) / 100.0, choice(e, "output"))
} else {
(f(e, "tolerance", cx) / 255.0 * 0.6 + 0.01, f(e, "feather", cx) / 50.0 * 0.2 + 0.01, 0.0, 0)
};
let dom = if kc[1] >= kc[0] && kc[1] >= kc[2] {
1
} else if kc[2] >= kc[0] {
2
} else {
0
};
img.px.par_chunks_mut(4).for_each(|p| {
if p[3] <= 0.0 {
return;
}
let c = enc([p[0] / p[3], p[1] / p[3], p[2] / p[3]]);
let ycc = filmcraft_color::rgb_to_ycbcr(c[0], c[1], c[2], filmcraft_color::Matrix::Bt709);
let d = ((ycc[1] - kycc[1]).powi(2) + (ycc[2] - kycc[2]).powi(2)).sqrt() + (ycc[0] - kycc[0]).abs() * 0.15;
let alpha = ((d - tol) / soft).clamp(0.0, 1.0);
let mut o = c;
if spill > 0.0 {
let others = (o[(dom + 1) % 3] + o[(dom + 2) % 3]) / 2.0;
if o[dom] > others {
o[dom] -= (o[dom] - others) * spill;
}
}
let a = p[3] * alpha;
let lo = dec(o);
match output {
1 => {
p.copy_from_slice(&[alpha * p[3], alpha * p[3], alpha * p[3], p[3]]);
}
_ => {
p[0] = lo[0] * a;
p[1] = lo[1] * a;
p[2] = lo[2] * a;
p[3] = a;
}
}
});
}
fn lumetri(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
let temp = f(e, "temperature", cx) / 100.0;
let tint = f(e, "tint", cx) / 100.0;
let exposure = 2f32.powf(f(e, "exposure", cx));
let contrast = f(e, "contrast", cx) / 100.0;
let hl = f(e, "highlights", cx) / 100.0;
let sh = f(e, "shadows", cx) / 100.0;
let wh = f(e, "whites", cx) / 100.0;
let bl = f(e, "blacks", cx) / 100.0;
let sat = f(e, "saturation", cx) / 100.0 * f(e, "creative_sat", cx) / 100.0;
let vib = f(e, "vibrance", cx) / 100.0;
let faded = f(e, "faded_film", cx) / 100.0;
let st = color(e, "shadow_tint", cx);
let ht = color(e, "highlight_tint", cx);
let va = f(e, "vignette_amount", cx);
let vmid = f(e, "vignette_midpoint", cx) / 100.0;
let vround = f(e, "vignette_roundness", cx) / 100.0;
let vfeather = f(e, "vignette_feather", cx) / 100.0;
let sharpen = f(e, "sharpen", cx) / 100.0;
let gains = [1.0 + 0.35 * temp, 1.0 - 0.3 * tint, 1.0 - 0.35 * temp];
let (w, h) = (img.w as f32, img.h as f32);
let aspect = w / h;
img.map_rgb(|c, x, y| {
// white balance + exposure in linear light
let lin = [c[0] * gains[0] * exposure, c[1] * gains[1] * exposure, c[2] * gains[2] * exposure];
let mut v = enc(lin);
// whites / blacks: endpoints
let b0 = -bl * 0.15;
let w0 = 1.0 - wh * 0.15;
v = v.map(|q| (q - b0) / (w0 - b0).max(1e-3));
// highlights / shadows: luma-weighted lift/compress, hue preserving
let l = 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
let ws = (1.0 - l).clamp(0.0, 1.0).powi(3);
let whl = l.clamp(0.0, 1.0).powi(3);
let nl = (l + sh * 0.35 * ws + hl * 0.35 * whl).max(0.0);
if l > 1e-5 {
let k = nl / l;
v = v.map(|q| q * k);
}
// contrast: smooth S-curve around mid grey
if contrast.abs() > 1e-4 {
let k = 1.0 + contrast;
v = v.map(|q| {
let q = q.clamp(0.0, 1.0);
let s = q * q * (3.0 - 2.0 * q);
if k >= 1.0 { q + (s - q) * (k - 1.0) } else { 0.5 + (q - 0.5) * k }
});
}
// faded film: lift blacks and compress
if faded > 0.0 {
v = v.map(|q| q * (1.0 - 0.25 * faded) + 0.12 * faded);
}
// split tone
let l2 = (0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2]).clamp(0.0, 1.0);
for k in 0..3 {
v[k] += (st[k] - 0.5) * 0.3 * (1.0 - l2) + (ht[k] - 0.5) * 0.3 * l2;
}
// saturation & vibrance
let l3 = 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
let cur_sat = v[0].max(v[1]).max(v[2]) - v[0].min(v[1]).min(v[2]);
let s = sat * (1.0 + vib * (1.0 - cur_sat.clamp(0.0, 1.0)));
v = v.map(|q| l3 + (q - l3) * s);
// vignette
if va.abs() > 1e-4 {
let nx = (x as f32 / w - 0.5) * 2.0 * (1.0 + vround * 0.0) * if vround < 0.0 { aspect.powf(-vround) } else { 1.0 };
let ny = (y as f32 / h - 0.5) * 2.0;
let d = (nx * nx + ny * ny).sqrt() / std::f32::consts::SQRT_2;
let edge = ((d - vmid * 0.9) / (vfeather.max(0.01) * 0.9)).clamp(0.0, 1.0);
let e2 = edge * edge * (3.0 - 2.0 * edge);
let k = 1.0 + va * 0.2 * e2;
v = v.map(|q| if va < 0.0 { q * k.max(0.0) } else { q + (1.0 - q) * (k - 1.0) });
}
dec(v)
});
lumetri_advanced(img, e, cx);
if sharpen.abs() > 1e-3 {
unsharp(img, 1.2 * cx.px_scale.max(0.35), sharpen.max(-1.0), 0.0);
}
}
// ---------- blur kernels ----------
/// Box radii for an n-pass box blur approximating a Gaussian of sigma (Kovesi / Wells).
fn boxes_for_gauss(sigma: f32, n: usize) -> Vec<usize> {
let wideal = ((12.0 * sigma * sigma / n as f32) + 1.0).sqrt();
let mut wl = wideal.floor() as i32;
if wl % 2 == 0 {
wl -= 1;
}
let wu = wl + 2;
let mideal = (12.0 * sigma * sigma - (n as i32 * wl * wl) as f32 - 4.0 * n as f32 * wl as f32 - 3.0 * n as f32) / (-4.0 * wl as f32 - 4.0);
let m = mideal.round() as i32;
(0..n as i32).map(|i| (((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize).collect()
}
fn box_rows(px: &mut [f32], w: usize, r: usize, repeat: bool) {
if r == 0 {
return;
}
px.par_chunks_mut(w * 4).for_each_init(
|| vec![0f32; w * 4],
|tmp, row| {
tmp.copy_from_slice(row);
let inv = 1.0 / (2 * r + 1) as f32;
let fetch = |i: isize| -> usize { if repeat { i.clamp(0, w as isize - 1) as usize } else { i as usize } };
let mut acc = [0f32; 4];
for i in -(r as isize)..=(r as isize) {
if repeat || (i >= 0 && (i as usize) < w) {
let j = fetch(i);
for k in 0..4 {
acc[k] += tmp[j * 4 + k];
}
}
}
for x in 0..w {
for k in 0..4 {
row[x * 4 + k] = acc[k] * inv;
}
let out_i = x as isize - r as isize;
let in_i = x as isize + r as isize + 1;
if repeat || out_i >= 0 {
let j = fetch(out_i);
for k in 0..4 {
acc[k] -= tmp[j * 4 + k];
}
}
if repeat || (in_i as usize) < w {
let j = fetch(in_i);
for k in 0..4 {
acc[k] += tmp[j * 4 + k];
}
}
}
},
);
}
fn transpose(img: &Image) -> Image {
let (w, h) = (img.w, img.h);
let mut out = Image::new(h, w);
const B: usize = 32;
out.px.par_chunks_mut(h * 4 * B).enumerate().for_each(|(bi, chunk)| {
let x0 = bi * B;
let rows = chunk.len() / (h * 4);
for y in 0..h {
for dx in 0..rows {
let x = x0 + dx;
let s = (y * w + x) * 4;
let d = (dx * h + y) * 4;
chunk[d..d + 4].copy_from_slice(&img.px[s..s + 4]);
}
}
});
out
}
/// Gaussian blur via 3 box passes per axis (O(1) per pixel for any radius).
pub fn gaussian(img: &mut Image, sigma_x: f32, sigma_y: f32, repeat_edge: bool) {
if sigma_x > 0.3 {
for r in boxes_for_gauss(sigma_x, 3) {
box_rows(&mut img.px, img.w, r, repeat_edge);
}
}
if sigma_y > 0.3 {
let mut t = transpose(img);
for r in boxes_for_gauss(sigma_y, 3) {
box_rows(&mut t.px, t.w, r, repeat_edge);
}
*img = transpose(&t);
}
}
fn unsharp(img: &mut Image, radius: f32, amount: f32, threshold: f32) {
let mut blurred = img.clone();
gaussian(&mut blurred, radius, radius, true);
img.px.par_chunks_mut(4).zip(blurred.px.par_chunks(4)).for_each(|(p, bq)| {
for k in 0..3 {
let d = p[k] - bq[k];
if d.abs() >= threshold * p[3] {
p[k] = (p[k] + d * amount).clamp(0.0, p[3].max(p[k] + d * amount).max(0.0));
p[k] = p[k].max(0.0);
}
}
});
}
fn directional_blur(img: &mut Image, len: f32, dir: f64) {
if len < 0.5 {
return;
}
let steps = (len.ceil() as usize).clamp(2, 64);
let (dx, dy) = ((dir.sin() as f32) * len, (-dir.cos() as f32) * len);
let src = img.clone();
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let mut acc = [0f32; 4];
for s in 0..steps {
let t = s as f32 / (steps - 1) as f32 - 0.5;
let p = src.sample_bilinear_clamped(x as f32 + 0.5 + dx * t, y as f32 + 0.5 + dy * t);
for k in 0..4 {
acc[k] += p[k];
}
}
for k in 0..4 {
row[x * 4 + k] = acc[k] / steps as f32;
}
}
});
}
fn median(img: &mut Image, r: isize) {
let src = img.clone();
let w = img.w;
img.px.par_chunks_mut(w * 4).enumerate().for_each_init(Vec::new, |buf: &mut Vec<f32>, (y, row)| {
for x in 0..w {
for k in 0..4 {
buf.clear();
for dy in -r..=r {
for dx in -r..=r {
buf.push(src.get_clamped(x as isize + dx, y as isize + dy)[k]);
}
}
let mid = buf.len() / 2;
buf.select_nth_unstable_by(mid, |a, b| a.total_cmp(b));
row[x * 4 + k] = buf[mid];
}
}
});
}
fn mosaic(img: &mut Image, bx: usize, by: usize) {
let (w, h) = (img.w, img.h);
let bw = (w as f32 / bx as f32).max(1.0);
let bh = (h as f32 / by as f32).max(1.0);
let src = img.clone();
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
let cy = ((y as f32 / bh).floor() + 0.5) * bh;
for x in 0..w {
let cxp = ((x as f32 / bw).floor() + 0.5) * bw;
let p = src.sample_bilinear_clamped(cxp, cy);
row[x * 4..x * 4 + 4].copy_from_slice(&p);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use filmcraft_project::find_effect;
fn cx() -> FxCtx<'static> {
FxCtx { t: Tick::ZERO, px_scale: 1.0, seconds: 0.0, timecode: "00:00:01:00", clip_name: "x" }
}
#[test]
fn every_effect_runs_and_stays_finite() {
for def in filmcraft_project::effect_defs() {
if def.kind != filmcraft_project::EffectKind::Video || def.intrinsic {
continue;
}
let mut img = Image::filled(24, 16, [0.2, 0.4, 0.1, 1.0]);
let mut e = def.instance();
// push params away from identity so code paths run
for (id, p) in e.params.iter_mut() {
if let ParamValue::Float(v) = &mut p.value
&& let Some(filmcraft_project::ParamKind::Float { soft_max, .. }) = def.param(id).map(|d| &d.kind)
{
*v = (*v + soft_max * 0.3).min(*soft_max);
}
}
apply(&mut img, &e, &cx());
assert!(img.px.iter().all(|v| v.is_finite()), "{}", def.id);
}
}
#[test]
fn gaussian_preserves_mean_and_blurs() {
let mut img = Image::new(64, 64);
for y in 28..36 {
for x in 28..36 {
let i = (y * 64 + x) * 4;
img.px[i..i + 4].copy_from_slice(&[1.0, 1.0, 1.0, 1.0]);
}
}
let sum0: f32 = img.px.iter().sum();
gaussian(&mut img, 4.0, 4.0, false);
let sum1: f32 = img.px.iter().sum();
assert!((sum0 - sum1).abs() / sum0 < 0.02, "{sum0} {sum1}");
assert!(img.get(32, 32)[0] < 0.9 && img.get(24, 32)[0] > 0.01);
}
#[test]
fn flip_and_crop() {
let mut img = Image::new(4, 1);
img.px[0..4].copy_from_slice(&[1.0, 0.0, 0.0, 1.0]);
apply(&mut img, &find_effect("horizontal_flip").unwrap().instance(), &cx());
assert_eq!(img.get(3, 0), [1.0, 0.0, 0.0, 1.0]);
let mut e = find_effect("crop").unwrap().instance();
e.params.get_mut("right").unwrap().value = ParamValue::Float(50.0);
apply(&mut img, &e, &cx());
assert_eq!(img.get(3, 0)[3], 0.0);
}
#[test]
fn identity_lumetri_is_identity() {
let mut img = Image::filled(8, 8, [0.18, 0.3, 0.05, 1.0]);
let before = img.clone();
apply(&mut img, &find_effect("lumetri").unwrap().instance(), &cx());
for (a, b) in img.px.iter().zip(&before.px) {
assert!((a - b).abs() < 2e-3, "{a} {b}");
}
}
}
/// Monotone cubic (Fritsch–Carlson) interpolation through sorted control points, as a LUT of `n`
/// entries over x ∈ [0, 1]. Monotone curves never overshoot, which keeps tone curves well-behaved.
pub fn curve_lut(points: &[[f32; 2]], n: usize) -> Vec<f32> {
let mut pts: Vec<[f32; 2]> = points.to_vec();
pts.sort_by(|a, b| a[0].total_cmp(&b[0]));
pts.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6);
if pts.len() < 2 {
let y = pts.first().map_or(0.0, |p| p[1]);
return if pts.is_empty() { (0..n).map(|i| i as f32 / (n - 1) as f32).collect() } else { vec![y; n] };
}
let m = pts.len();
let d: Vec<f32> = (0..m - 1).map(|i| (pts[i + 1][1] - pts[i][1]) / (pts[i + 1][0] - pts[i][0]).max(1e-6)).collect();
let mut t = vec![0f32; m];
t[0] = d[0];
t[m - 1] = d[m - 2];
for i in 1..m - 1 {
t[i] = if d[i - 1] * d[i] <= 0.0 { 0.0 } else { (d[i - 1] + d[i]) / 2.0 };
}
for i in 0..m - 1 {
if d[i].abs() < 1e-9 {
t[i] = 0.0;
t[i + 1] = 0.0;
continue;
}
let a = t[i] / d[i];
let b = t[i + 1] / d[i];
let h = a * a + b * b;
if h > 9.0 {
let k = 3.0 / h.sqrt();
t[i] = k * a * d[i];
t[i + 1] = k * b * d[i];
}
}
(0..n)
.map(|j| {
let x = j as f32 / (n - 1) as f32;
if x <= pts[0][0] {
return pts[0][1];
}
if x >= pts[m - 1][0] {
return pts[m - 1][1];
}
let i = pts.partition_point(|p| p[0] <= x) - 1;
let hh = pts[i + 1][0] - pts[i][0];
let u = (x - pts[i][0]) / hh;
let (h00, h10, h01, h11) = (2.0 * u * u * u - 3.0 * u * u + 1.0, u * u * u - 2.0 * u * u + u, -2.0 * u * u * u + 3.0 * u * u, u * u * u - u * u);
h00 * pts[i][1] + h10 * hh * t[i] + h01 * pts[i + 1][1] + h11 * hh * t[i + 1]
})
.collect()
}
/// Periodic (hue) curve: points around the colour wheel, neutral 0.5 where there are none.
fn hue_lut(points: &[[f32; 2]], n: usize) -> Option<Vec<f32>> {
if points.is_empty() {
return None;
}
// wrap: repeat points one period left and right, then sample 0..1
let mut ext = Vec::new();
for off in [-1.0f32, 0.0, 1.0] {
for p in points {
ext.push([p[0] + off, p[1]]);
}
}
let lut = curve_lut(&ext.iter().map(|p| [(p[0] + 1.0) / 3.0, p[1]]).collect::<Vec<_>>(), n * 3);
Some(lut[n..2 * n].to_vec())
}
fn curve_param(e: &EffectInstance, id: &str) -> Option<Vec<[f32; 2]>> {
e.param(id).and_then(|p| p.value.as_curve().map(|c| c.to_vec()))
}
fn is_identity_curve(c: &[[f32; 2]]) -> bool {
c.iter().all(|p| (p[0] - p[1]).abs() < 1e-4)
}
/// Wheel offset (zero-mean RGB direction for a wheel position).
fn wheel_rgb(v: Vec2) -> [f32; 3] {
let len = (v.x * v.x + v.y * v.y).sqrt().min(1.0) as f32;
if len < 1e-5 {
return [0.0; 3];
}
let a = (v.y).atan2(v.x) as f32;
let tau = std::f32::consts::TAU;
[a.cos() * len, (a - tau / 3.0).cos() * len, (a + tau / 3.0).cos() * len]
}
/// Looks: our own procedural grades (no third-party LUTs), applied on display-encoded colour.
fn apply_look(look: u32, c: [f32; 3]) -> [f32; 3] {
let l = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
let mix = |a: [f32; 3], b: [f32; 3], k: f32| [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
let s_curve = |v: f32, k: f32| {
let x = v.clamp(0.0, 1.0);
x + (x * x * (3.0 - 2.0 * x) - x) * k
};
match look {
1 => {
// teal shadows, orange highlights
let shadow = [0.0, 0.08, 0.1];
let high = [0.1, 0.04, -0.06];
let c = [c[0] + shadow[0] * (1.0 - l) + high[0] * l, c[1] + shadow[1] * (1.0 - l) + high[1] * l, c[2] + shadow[2] * (1.0 - l) + high[2] * l];
c.map(|v| s_curve(v, 0.35))
}
2 => {
let c = [c[0] * 1.06 + 0.02, c[1] * 1.0 + 0.01, c[2] * 0.9];
mix(c, [l, l, l], 0.15).map(|v| v * 0.94 + 0.04)
}
3 => [c[0] * 0.9, c[1] * 0.98, c[2] * 1.1 + 0.02].map(|v| s_curve(v, 0.2)),
4 => mix(c, [l, l, l], 0.55).map(|v| s_curve(v, 0.6)),
5 => mix(c, [l, l, l], 0.25).map(|v| 0.08 + v * 0.84),
6 => [l, l, l].map(|v| s_curve(v, 0.3)),
7 => [c[0] * 1.1 + 0.03, c[1] * 1.02 + 0.01, c[2] * 0.82].map(|v| s_curve(v, 0.15)),
8 => [c[0] * 0.75, c[1] * 0.85, c[2] * 1.15].map(|v| v * 0.8),
_ => c,
}
}
fn lumetri_advanced(img: &mut Image, e: &EffectInstance, cx: &FxCtx) {
const N: usize = 1024;
let lut = |id: &str| curve_param(e, id).filter(|c| !is_identity_curve(c)).map(|c| curve_lut(&c, N));
let (luma_c, red_c, green_c, blue_c) = (lut("curve_luma"), lut("curve_red"), lut("curve_green"), lut("curve_blue"));
let hue = |id: &str| curve_param(e, id).and_then(|c| hue_lut(&c, N));
let (hvs, hvh, hvl, lvs, svs) = (hue("hue_vs_sat"), hue("hue_vs_hue"), hue("hue_vs_luma"), hue("luma_vs_sat"), hue("sat_vs_sat"));
let look = choice(e, "look");
let look_k = f(e, "look_intensity", cx) / 100.0;
let v2 = |id: &str| e.param(id).map(|p| p.vec2_at(cx.t)).unwrap_or_default();
let (ws, wm, wh) = (wheel_rgb(v2("wheel_shadows")), wheel_rgb(v2("wheel_midtones")), wheel_rgb(v2("wheel_highlights")));
let (ls, lm, lh) = (f(e, "wheel_shadows_l", cx) / 100.0, f(e, "wheel_midtones_l", cx) / 100.0, f(e, "wheel_highlights_l", cx) / 100.0);
let wheels = ws.iter().chain(&wm).chain(&wh).any(|v| v.abs() > 1e-5) || ls.abs() + lm.abs() + lh.abs() > 1e-5;
let hsl_on = b(e, "hsl_on");
let any = luma_c.is_some()
|| red_c.is_some()
|| green_c.is_some()
|| blue_c.is_some()
|| hvs.is_some()
|| hvh.is_some()
|| hvl.is_some()
|| lvs.is_some()
|| svs.is_some()
|| look > 0
|| wheels
|| hsl_on;
if !any {
return;
}
let (hc, hr) = (f(e, "hsl_hue", cx) / 360.0, (f(e, "hsl_hue_range", cx) / 360.0).max(1e-3));
let (smin, lmin, lmax) = (f(e, "hsl_sat_min", cx) / 100.0, f(e, "hsl_luma_min", cx) / 100.0, f(e, "hsl_luma_max", cx) / 100.0);
let soft = (f(e, "hsl_soft", cx) / 100.0 * 0.3).max(0.01);
let show_mask = choice(e, "hsl_show_mask");
let (htemp, htint, hsat, hshift) =
(f(e, "hsl_temp", cx) / 100.0, f(e, "hsl_tint", cx) / 100.0, f(e, "hsl_sat", cx) / 100.0, f(e, "hsl_hue_shift", cx) / 360.0);
let sample = |l: &Vec<f32>, x: f32| {
let p = x.clamp(0.0, 1.0) * (N - 1) as f32;
let i = p as usize;
let j = (i + 1).min(N - 1);
l[i] + (l[j] - l[i]) * (p - i as f32)
};
img.map_rgb(|c, _, _| {
let mut v = enc(c);
if look > 0 {
let lk = apply_look(look, v);
v = [v[0] + (lk[0] - v[0]) * look_k, v[1] + (lk[1] - v[1]) * look_k, v[2] + (lk[2] - v[2]) * look_k];
}
if wheels {
let l = (0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2]).clamp(0.0, 1.0);
let wsh = (1.0 - l).powi(2);
let whi = l * l;
let wmid = (1.0 - wsh - whi).max(0.0);
for k in 0..3 {
// lift (shadows), gamma-ish (midtones), gain (highlights)
v[k] += (ws[k] * 0.3 + ls * 0.3) * wsh;
v[k] += (wm[k] * 0.3 + lm * 0.3) * wmid;
v[k] *= 1.0 + (wh[k] * 0.5 + lh * 0.5) * whi;
}
}
if let Some(l) = &luma_c {
v = v.map(|q| sample(l, q));
}
if let Some(l) = &red_c {
v[0] = sample(l, v[0]);
}
if let Some(l) = &green_c {
v[1] = sample(l, v[1]);
}
if let Some(l) = &blue_c {
v[2] = sample(l, v[2]);
}
if hvs.is_some() || hvh.is_some() || hvl.is_some() || lvs.is_some() || svs.is_some() {
let mut h = rgb_to_hsl(v[0].clamp(0.0, 1.0), v[1].clamp(0.0, 1.0), v[2].clamp(0.0, 1.0));
let (h0, s0, l0) = (h[0], h[1], h[2]);
if let Some(t) = &hvh {
h[0] = (h[0] + (sample(t, h0) - 0.5)).rem_euclid(1.0);
}
let mut sm = 1.0;
if let Some(t) = &hvs {
sm *= sample(t, h0) * 2.0;
}
if let Some(t) = &lvs {
sm *= sample(t, l0) * 2.0;
}
if let Some(t) = &svs {
sm *= sample(t, s0) * 2.0;
}
h[1] = (h[1] * sm).clamp(0.0, 1.0);
if let Some(t) = &hvl {
h[2] = (h[2] + (sample(t, h0) - 0.5) * 0.5).clamp(0.0, 1.0);
}
v = hsl_to_rgb(h[0], h[1], h[2]);
}
if hsl_on {
let h = rgb_to_hsl(v[0].clamp(0.0, 1.0), v[1].clamp(0.0, 1.0), v[2].clamp(0.0, 1.0));
let dh = (h[0] - hc).abs().min(1.0 - (h[0] - hc).abs());
let mh = 1.0 - ((dh - hr / 2.0) / soft).clamp(0.0, 1.0);
let ms = ((h[1] - smin) / soft).clamp(0.0, 1.0);
let ml = ((h[2] - lmin) / soft).clamp(0.0, 1.0).min(((lmax - h[2]) / soft).clamp(0.0, 1.0));
let m = mh * ms * ml;
match show_mask {
1 => {
let g = h[2];
v = [g + (v[0] - g) * m, g + (v[1] - g) * m, g + (v[2] - g) * m];
}
2 => v = v.map(|q| q * m),
3 => v = [m, m, m],
_ => {
let mut hh = h;
hh[0] = (hh[0] + hshift).rem_euclid(1.0);
hh[1] = (hh[1] * hsat).clamp(0.0, 1.0);
let mut c2 = hsl_to_rgb(hh[0], hh[1], hh[2]);
c2[0] *= 1.0 + 0.25 * htemp;
c2[2] *= 1.0 - 0.25 * htemp;
c2[1] *= 1.0 - 0.2 * htint;
v = [v[0] + (c2[0] - v[0]) * m, v[1] + (c2[1] - v[1]) * m, v[2] + (c2[2] - v[2]) * m];
}
}
}
dec(v)
});
}
#[cfg(test)]
mod curve_tests {
use super::*;
#[test]
fn identity_and_monotone() {
let l = curve_lut(&[[0.0, 0.0], [1.0, 1.0]], 64);
for (i, v) in l.iter().enumerate() {
assert!((v - i as f32 / 63.0).abs() < 1e-4);
}
let s = curve_lut(&[[0.0, 0.0], [0.25, 0.15], [0.75, 0.85], [1.0, 1.0]], 256);
assert!(s.windows(2).all(|w| w[1] >= w[0] - 1e-6), "monotone");
let h = hue_lut(&[[0.0, 0.8], [0.5, 0.2]], 64).unwrap();
assert!((h[0] - h[63]).abs() < 0.05, "periodic");
}
}