diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 45dccc78..2b296a36 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -90,6 +90,8 @@ pub use transition::place_layer; pub use noise::remove_grain_level; pub use noise3::{FractalGpu, fractal_gpu}; pub use time_fx::{posterized_time, time_frames}; +// effectcraft-gpu fx_tone. +pub use ocio::color_stabilizer_maps; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -856,6 +858,7 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.equalize", "ec.color.shadowhighlight", "ec.color.cccolorneutralizer", + "ec.color.colorstabilizer", // effectcraft-gpu fx_stylize (stylize, distort) // // effectcraft-gpu fx_noise (noise, blur, time) diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index 6dd17c4f..1969f50a 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -905,6 +905,46 @@ fn curve3(xs: [f64; 3], ys: [f64; 3], v: f64) -> f64 { a.1 + (c.1 - a.1) * (v - a.0) / (c.0 - a.0).max(1e-9) } +/// Color Stabilizer's correction of buffer `b` as one piecewise-linear map per channel (points +/// with increasing x; the end segments extend), the same maps [`color_stabilizer`] applies. +/// `None` without a host or reference frame (the layer passes through). (The GPU effect.) +pub fn color_stabilizer_maps(ctx: &EffectCtx, b: &Buf) -> Option<[Vec<(f64, f64)>; 3]> { + let reference = ctx.env.host?.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index)?; + let r = ctx.params.f("sampleSize").max(0.5); + let pts = [ctx.params.v2("blackPoint"), ctx.params.v2("midPoint"), ctx.params.v2("whitePoint")]; + let cur = pts.map(|pt| sample_mean(b, pt, r)); + let refv = pts.map(|pt| sample_mean(&reference, pt, r)); + let mode = ctx.params.e("stabilize"); + Some([0, 1, 2].map(|k| { + let offset = |c0: f64, r0: f64| vec![(0.0, r0 - c0), (1.0, 1.0 + r0 - c0)]; + match mode { + 0 => offset(cur[0][k], refv[0][k]), + 1 => { + let (c0, c1, r0, r1) = (cur[0][k], cur[2][k], refv[0][k], refv[2][k]); + if (c1 - c0).abs() < 1e-6 { + offset(c0, r0) + } else if c0 < c1 { + vec![(c0, r0), (c1, r1)] + } else { + vec![(c1, r1), (c0, r0)] + } + } + _ => { + let mut v = vec![(0.0, 0.0)]; + let mut pairs: Vec<(f64, f64)> = (0..3).map(|i| (cur[i][k], refv[i][k])).collect(); + pairs.sort_by(|a, b| a.0.total_cmp(&b.0)); + for (x, y) in pairs { + if x > v.last().expect("non-empty").0 + 1e-4 && x < 1.0 - 1e-4 { + v.push((x, y)); + } + } + v.push((1.0, 1.0)); + v + } + } + })) +} + fn color_stabilizer(ctx: &EffectCtx, mut b: Buf) -> Buf { let Some(host) = ctx.env.host else { return b }; let Some(reference) = host.self_at(ctx.params.f("referenceFrame"), ctx.env.effect_index) else { return b }; diff --git a/crates/gpu/src/fx_tone.rs b/crates/gpu/src/fx_tone.rs index 1f829b5e..8025ffdd 100644 --- a/crates/gpu/src/fx_tone.rs +++ b/crates/gpu/src/fx_tone.rs @@ -39,6 +39,7 @@ pub(crate) const IDS: &[&str] = &[ "ec.color.equalize", "ec.color.shadowhighlight", "ec.color.cccolorneutralizer", + "ec.color.colorstabilizer", ]; /// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. @@ -319,6 +320,25 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { p.f[0] = [level, level * (1.0 - comp), 0.0, 0.0]; p.f[1] = rgb(pr.color("gamutWarningColor")); } + "ec.color.colorstabilizer" => { + // The sample means are measured on the CPU (the frame is read back). + if ctx.env.host.is_none() { + return Some(b); + } + let img = e.download(&b.img)?; + let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale }; + let Some(maps) = effectcraft_effects::color_stabilizer_maps(ctx, &cpu) else { return Some(b) }; + let mut d = vec![]; + for m in &maps { + d.push(m.len() as f32); + for i in 0..5 { + let (x, y) = m.get(i).copied().unwrap_or((0.0, 0.0)); + d.extend([x as f32, y as f32]); + } + } + p.u[0][0] = 24; + data = Some(d); + } "ec.color.cccolorneutralizer" => { let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")]; for (i, (u, bal)) in pairs.iter().enumerate() { diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index a6703cca..39db3458 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -147,6 +147,7 @@ fn fxt_eq_look(table: u32, n: u32, v: f32) -> f32 { // 16 Auto Levels / Contrast / Color apply 17 Equalize apply 18 Invert (HLS / YIQ channels) // 19 luminance plane 20 Shadow/Highlight (aux = (shadow base, highlight base)) // 21 pack src.x, aux.x 22 Shadow/Highlight clip remap 23 CC Color Neutralizer +// 24 Color Stabilizer (data: per channel n, then 5 × (x, y)) @compute @workgroup_size(16, 16) fn fxt_point(@builtin(global_invocation_id) gid: vec3) { let dims = out_dims(); @@ -690,6 +691,29 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3) { r = fxt_mix(r, c, P.f[3].y); o = vec4(r * a, a); } + case 24u: { + if (a <= 0.0) { + break; + } + var r: vec3; + for (var k = 0u; k < 3u; k++) { + let base = k * 11u; + let n = u32(data[base]); + let v = c[k]; + // slice::partition_point(x < v), clamped to 1..n-1. + var i = 0u; + while (i < n && data[base + 1u + 2u * i] < v) { + i++; + } + i = clamp(i, 1u, n - 1u); + let ax = data[base + 1u + 2u * (i - 1u)]; + let ay = data[base + 2u + 2u * (i - 1u)]; + let cx = data[base + 1u + 2u * i]; + let cy = data[base + 2u + 2u * i]; + r[k] = ay + (cy - ay) * (v - ax) / max(cx - ax, 1e-9); + } + o = vec4(r * a, a); + } default: {} } textureStore(out, p, o); diff --git a/crates/gpu/src/tests_fx_tone.rs b/crates/gpu/src/tests_fx_tone.rs index 50b6f55a..61c977d1 100644 --- a/crates/gpu/src/tests_fx_tone.rs +++ b/crates/gpu/src/tests_fx_tone.rs @@ -1,9 +1,13 @@ //! GPU colour-correction family effects vs the CPU effects (the oracle): direct on a buffer at //! full and half resolution, as adjustment, and composited at 8 and 32 bpc. -use effectcraft_keyframe::Value; +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource, build}; +use effectcraft_render::RenderOpts; +use effectcraft_time::{FrameRate, Tick}; -use crate::tests::{c, effect_case, n}; +use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; fn e(v: u32) -> Value { Value::Enum(v) @@ -142,3 +146,40 @@ fn shadow_highlight_neutralizer_invert() { effect_case("ec.channel.invert", &[("channel", e(ch)), ("blend", n(20.0))]); } } + +/// Color Stabilizer reads its reference frame through the effect host: a precomp whose content +/// moves, at a nonzero time (the direct cases have no host and pass the layer through). +#[test] +fn color_stabilizer() { + effect_case("ec.color.colorstabilizer", &[]); + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for mode in [0, 1, 2] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut a = s.footage(50, 34); + let pos = a.props.prop_mut("transform/position").unwrap(); + pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(22.0, 18.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(38.0, 24.0))]; + let op = a.props.prop_mut("transform/opacity").unwrap(); + op.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(40.0))]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); + let vals = [ + ("stabilize", e(mode)), + ("blackPoint", Value::Vec2([18.0, 20.0])), + ("midPoint", Value::Vec2([30.0, 18.0])), + ("whitePoint", Value::Vec2([42.0, 22.0])), + ("sampleSize", n(4.0)), + ]; + s.effect(&mut l, "ec.color.colorstabilizer", &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let t = Tick::from_seconds_f64(0.5); + let label = format!("color stabilizer {mode} {depth:?}"); + check(&label, compare_at(&s, opts(), t), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0); + } + } +}