M13.9: gpu Color Stabilizer apply pass

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 08:28:27 -04:00
parent 6cbb6bfb93
commit 6e556690ad
5 changed files with 130 additions and 2 deletions

View File

@ -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)

View File

@ -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 };

View File

@ -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<GBuf> {
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() {

View File

@ -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<u32>) {
let dims = out_dims();
@ -690,6 +691,29 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3<u32>) {
r = fxt_mix(r, c, P.f[3].y);
o = vec4<f32>(r * a, a);
}
case 24u: {
if (a <= 0.0) {
break;
}
var r: vec3<f32>;
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<f32>(r * a, a);
}
default: {}
}
textureStore(out, p, o);

View File

@ -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);
}
}
}