mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
M9.11: Key Light (Keylight 1.2-equivalent keyer) and mocha shape
Key Light is registered under a generic name (Keylight is a vendor trademark); lookup() resolves "Keylight (1.2)" to it. mocha shape reads a documented JSON shape format (Mocha's own export format is not public). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
451f63cc0d
commit
f95a600722
@@ -17,6 +17,5 @@ effectcraft-project = { workspace = true }
|
||||
effectcraft-raster = { workspace = true }
|
||||
effectcraft-track = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
//! Key Light — a full screen-colour keyer with the controls of After Effects' bundled
|
||||
//! "Keylight (1.2)" (that name is a trademark of its vendor, so the effect is registered as
|
||||
//! **Key Light**; `lookup("Keylight (1.2)")` finds it through [`KEYLIGHT_ALIASES`]).
|
||||
//!
|
||||
//! The method is the textbook *screen difference* key, written from the public description of
|
||||
//! the controls:
|
||||
//!
|
||||
//! * colours are first neutralised by the **Alpha Bias** / **Despill Bias** colours (each channel
|
||||
//! scaled so the bias colour becomes grey — mid grey = no change);
|
||||
//! * the **screen difference** of a pixel is its screen-primary channel minus a balance-weighted
|
||||
//! mix of the other two (**Screen Balance**); the raw matte is
|
||||
//! `1 − Screen Gain × diff(pixel) / diff(screen colour)`;
|
||||
//! * the **Screen Matte** is then clipped (Clip Black / White, with Clip Rollback restoring edge
|
||||
//! detail), despotted (morphological open/close: Despot Black / White), shrunk or grown and
|
||||
//! softened, combined with the **Inside** / **Outside** masks and the source alpha;
|
||||
//! * the foreground is the source with the screen colour removed in proportion to the
|
||||
//! transparency (`(c − (1 − α)·S) / α`), then **despilled** (the primary is limited to the
|
||||
//! balanced secondary), with **Replace Method** colouring areas whose alpha was raised by
|
||||
//! clipping or the inside mask, and optional foreground and edge colour correction.
|
||||
//!
|
||||
//! Views: Source, Source Alpha, Corrected Source, Colour Correction Edges, Screen Matte, Inside
|
||||
//! Mask, Outside Mask, Combined Matte, Status, Intermediate Result and Final Result.
|
||||
|
||||
use effectcraft_color::luminance;
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use effectcraft_raster::{Image, gaussian_blur};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::util::{Plane, gauss_plane, morph_frac, point_in_poly, premul, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
/// Other names the effect answers to in [`crate::lookup`].
|
||||
pub const KEYLIGHT_ALIASES: &[&str] = &["Keylight (1.2)", "Keylight"];
|
||||
|
||||
pub const VIEWS: [&str; 11] = [
|
||||
"Source",
|
||||
"Source Alpha",
|
||||
"Corrected Source",
|
||||
"Colour Correction Edges",
|
||||
"Screen Matte",
|
||||
"Inside Mask",
|
||||
"Outside Mask",
|
||||
"Combined Matte",
|
||||
"Status",
|
||||
"Intermediate Result",
|
||||
"Final Result",
|
||||
];
|
||||
|
||||
const REPLACE: [&str; 4] = ["None", "Source", "Hard Colour", "Soft Colour"];
|
||||
|
||||
fn pct(d: f64) -> (Value, ParamUi) {
|
||||
(num(d), slider(0.0, 100.0, 0.0, 100.0, 1))
|
||||
}
|
||||
|
||||
fn mask_popup() -> ParamUi {
|
||||
popup(&["None", "Mask 1", "Mask 2", "Mask 3", "Mask 4", "Mask 5", "Mask 6", "Mask 7", "Mask 8", "Mask 9", "Mask 10"])
|
||||
}
|
||||
|
||||
/// Index of the screen's primary channel and the two others (larger-in-screen first).
|
||||
fn primary(s: [f32; 3]) -> (usize, usize, usize) {
|
||||
let pi = if s[1] >= s[0] && s[1] >= s[2] {
|
||||
1
|
||||
} else if s[2] >= s[0] {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let (a, b) = match pi {
|
||||
0 => (1, 2),
|
||||
1 => (0, 2),
|
||||
_ => (0, 1),
|
||||
};
|
||||
if s[a] >= s[b] { (pi, a, b) } else { (pi, b, a) }
|
||||
}
|
||||
|
||||
/// Screen difference: primary minus the balance-weighted secondaries.
|
||||
#[inline]
|
||||
pub fn screen_diff(c: [f32; 3], pi: usize, o1: usize, o2: usize, bal: f32) -> f32 {
|
||||
c[pi] - (bal * c[o1] + (1.0 - bal) * c[o2])
|
||||
}
|
||||
|
||||
/// Scale channels so `bias` becomes neutral (grey keeps everything as is).
|
||||
#[inline]
|
||||
fn neutralise(c: [f32; 3], bias: [f32; 3]) -> [f32; 3] {
|
||||
let l = luminance(bias[0], bias[1], bias[2]).max(1e-4);
|
||||
[0, 1, 2].map(|i| c[i] * l / bias[i].max(1e-4))
|
||||
}
|
||||
|
||||
fn mask_coverage(ctx: &EffectCtx, b: &Buf, idx: u32, softness: f64, invert: bool) -> Option<Plane> {
|
||||
let shape = ctx.env.masks.get((idx as usize).checked_sub(1)?)?;
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let mut pl = Plane::new(w, h);
|
||||
let inv = 1.0 / b.scale.max(1e-9);
|
||||
pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let lx = (x as f64 + 0.5 - b.offset[0]) * inv;
|
||||
let ly = (y as f64 + 0.5 - b.offset[1]) * inv;
|
||||
*v = if point_in_poly(&shape.points, lx, ly) != shape.inverted { 1.0 } else { 0.0 };
|
||||
}
|
||||
});
|
||||
let s = softness * b.scale;
|
||||
if s > 0.0 {
|
||||
pl = gauss_plane(&pl, s * 0.5, s * 0.5);
|
||||
}
|
||||
if invert {
|
||||
pl = pl.map(|v| 1.0 - v);
|
||||
}
|
||||
Some(pl)
|
||||
}
|
||||
|
||||
/// Saturation / contrast / brightness correction of a straight colour.
|
||||
fn correct(c: [f32; 3], sat: f32, contrast: f32, bright: f32) -> [f32; 3] {
|
||||
let l = luminance(c[0], c[1], c[2]);
|
||||
let c = c.map(|v| l + (v - l) * sat);
|
||||
c.map(|v| ((v - 0.5) * contrast + 0.5) * bright)
|
||||
}
|
||||
|
||||
fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let pr = ctx.params;
|
||||
let view = pr.e("view");
|
||||
if view == 0 {
|
||||
return b;
|
||||
}
|
||||
let sc = pr.color("screenColour");
|
||||
let screen = [sc[0], sc[1], sc[2]];
|
||||
let (pi, o1, o2) = primary(screen);
|
||||
let gain = pr.f("screenGain") as f32 / 100.0;
|
||||
let bal = pr.f("screenBalance") as f32 / 100.0;
|
||||
let dbias = pr.color("despillBias");
|
||||
let abias = if pr.b("lockBiasesTogether") { dbias } else { pr.color("alphaBias") };
|
||||
let (dbias, abias) = ([dbias[0], dbias[1], dbias[2]], [abias[0], abias[1], abias[2]]);
|
||||
let s_alpha = neutralise(screen, abias);
|
||||
let sd = screen_diff(s_alpha, pi, o1, o2, bal).max(1e-3);
|
||||
let cb = pr.f("clipBlack") as f32 / 100.0;
|
||||
let cw = pr.f("clipWhite") as f32 / 100.0;
|
||||
let rollback = pr.f("clipRollback") * b.scale;
|
||||
let preblur = pr.f("screenPreblur") * b.scale;
|
||||
let sg = pr.f("screenShrinkGrow") * b.scale;
|
||||
let soft = pr.f("screenSoftness") * b.scale;
|
||||
let despot_b = pr.f("screenDespotBlack") * b.scale;
|
||||
let despot_w = pr.f("screenDespotWhite") * b.scale;
|
||||
|
||||
let src = b.img.clone();
|
||||
let blurred;
|
||||
let key_src: &Image = if preblur > 0.0 {
|
||||
blurred = gaussian_blur(&src, preblur * 0.5, preblur * 0.5, true);
|
||||
&blurred
|
||||
} else {
|
||||
&src
|
||||
};
|
||||
// Raw screen matte (opacity of the foreground).
|
||||
let raw = Plane::from_image(key_src, |px| {
|
||||
let (c, _) = unpremul(px);
|
||||
let d = screen_diff(neutralise(c, abias), pi, o1, o2, bal);
|
||||
(1.0 - gain * d / sd).clamp(0.0, 1.0)
|
||||
});
|
||||
let clip = |v: f32| ((v - cb) / (cw - cb).max(1e-4)).clamp(0.0, 1.0);
|
||||
let mut m = raw.map(clip);
|
||||
if rollback > 0.0 {
|
||||
// Restore edge detail lost by clipping within `rollback` pixels of the clipped matte.
|
||||
let grown = morph_frac(&m, rollback, true);
|
||||
let shrunk = morph_frac(&m, rollback, false);
|
||||
m = Plane {
|
||||
w: m.w,
|
||||
h: m.h,
|
||||
data: (0..m.data.len())
|
||||
.into_par_iter()
|
||||
.map(|i| if grown.data[i] > shrunk.data[i] + 1e-4 { m.data[i].max(raw.data[i]).min(grown.data[i]) } else { m.data[i] })
|
||||
.collect(),
|
||||
};
|
||||
}
|
||||
if despot_b > 0.0 {
|
||||
// Fill small holes: close (dilate then erode).
|
||||
m = morph_frac(&morph_frac(&m, despot_b, true), despot_b, false);
|
||||
}
|
||||
if despot_w > 0.0 {
|
||||
// Remove small specks: open (erode then dilate).
|
||||
m = morph_frac(&morph_frac(&m, despot_w, false), despot_w, true);
|
||||
}
|
||||
if sg < 0.0 {
|
||||
m = morph_frac(&m, -sg, false);
|
||||
} else if sg > 0.0 {
|
||||
m = morph_frac(&m, sg, true);
|
||||
}
|
||||
if soft > 0.0 {
|
||||
m = gauss_plane(&m, soft * 0.5, soft * 0.5);
|
||||
}
|
||||
let screen_matte = m.clone();
|
||||
let inside = mask_coverage(ctx, &b, pr.e("insideMask"), pr.f("insideMaskSoftness"), pr.b("invertInsideMask"));
|
||||
let outside = mask_coverage(ctx, &b, pr.e("outsideMask"), pr.f("outsideMaskSoftness"), pr.b("invertOutsideMask"));
|
||||
let src_alpha_mode = pr.e("sourceAlpha");
|
||||
let n = src.data.len();
|
||||
let combined: Vec<f32> = (0..n)
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let mut a = screen_matte.data[i];
|
||||
let sa = src.data[i][3].clamp(0.0, 1.0);
|
||||
let ins = inside.as_ref().map_or(0.0, |p| p.data[i]);
|
||||
let ins = if src_alpha_mode == 1 { ins.max(sa) } else { ins };
|
||||
a = a.max(ins);
|
||||
if let Some(o) = &outside {
|
||||
a *= 1.0 - o.data[i];
|
||||
}
|
||||
if src_alpha_mode == 2 {
|
||||
a *= sa;
|
||||
}
|
||||
a.clamp(0.0, 1.0)
|
||||
})
|
||||
.collect();
|
||||
let edges: Option<Vec<f32>> = pr.b("enableEdgeColourCorrection").then(|| {
|
||||
let grow = pr.f("edgeGrow") * b.scale;
|
||||
let hard = (pr.f("edgeHardness") / 100.0) as f32;
|
||||
let esoft = pr.f("edgeSoftness") * b.scale;
|
||||
// Edge band: where the (grown) combined matte is partial.
|
||||
let cm = Plane { w: m.w, h: m.h, data: combined.clone() };
|
||||
let band = cm.map(|a| if a > 1e-3 && a < 0.999 { 1.0 } else { 0.0 });
|
||||
let mut band = if grow > 0.0 { morph_frac(&band, grow, true) } else { band };
|
||||
if esoft > 0.0 {
|
||||
band = gauss_plane(&band, esoft * 0.5, esoft * 0.5);
|
||||
}
|
||||
band.data.iter().map(|v| (v * (1.0 + hard * 4.0)).min(1.0)).collect()
|
||||
});
|
||||
let replace = pr.e("replaceMethod");
|
||||
let rc = pr.color("replaceColour");
|
||||
let in_replace = pr.e("insideReplaceMethod");
|
||||
let in_rc = pr.color("insideReplaceColour");
|
||||
let fg_cc = pr.b("enableColourCorrection");
|
||||
let (fs, fc, fbr) = (pr.f("saturation") as f32 / 100.0, pr.f("contrast") as f32 / 100.0 + 1.0, pr.f("brightness") as f32 / 100.0 + 1.0);
|
||||
let (es, ec, ebr) = (pr.f("edgeSaturation") as f32 / 100.0, pr.f("edgeContrast") as f32 / 100.0 + 1.0, pr.f("edgeBrightness") as f32 / 100.0 + 1.0);
|
||||
let unpremultiply = pr.b("unpremultiplyResult");
|
||||
let crops = [pr.f("cropLeft"), pr.f("cropRight"), pr.f("cropTop"), pr.f("cropBottom")].map(|v| v / 100.0);
|
||||
let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]);
|
||||
let w = b.img.width as usize;
|
||||
let (scale, off) = (b.scale, b.offset);
|
||||
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
|
||||
let (c, _) = unpremul(src.data[i]);
|
||||
let a = combined[i];
|
||||
let sm = screen_matte.data[i];
|
||||
// Source crops (layer fractions from each edge).
|
||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
let (lx, ly) = ((x - off[0]) / scale / lw.max(1e-9), (y - off[1]) / scale / lh.max(1e-9));
|
||||
let cropped = lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3];
|
||||
// Screen removal on the raw (unclipped) transparency, then despill.
|
||||
let ar = raw.data[i].max(1e-4);
|
||||
let mut fg = [0, 1, 2].map(|k| ((c[k] - (1.0 - ar) * screen[k]) / ar).max(0.0));
|
||||
let nfg = neutralise(fg, dbias);
|
||||
let spill = screen_diff(nfg, pi, o1, o2, bal).max(0.0);
|
||||
let l0 = (luminance(dbias[0], dbias[1], dbias[2]) / dbias[pi].max(1e-4)).max(1e-4);
|
||||
fg[pi] -= spill / l0;
|
||||
let intermediate = fg;
|
||||
// Replace colour where the alpha was raised beyond the raw screen matte.
|
||||
let raised = (sm - raw.data[i]).max(0.0);
|
||||
let raised_in = inside.as_ref().map_or(0.0, |p| (p.data[i] - sm).max(0.0));
|
||||
let apply_replace = |fg: [f32; 3], method: u32, rc: [f32; 4], amt: f32| -> [f32; 3] {
|
||||
if amt <= 0.0 {
|
||||
return fg;
|
||||
}
|
||||
match method {
|
||||
1 => [0, 1, 2].map(|k| fg[k] + (c[k] - fg[k]) * amt),
|
||||
2 => [rc[0], rc[1], rc[2]],
|
||||
3 => {
|
||||
let l = luminance(c[0], c[1], c[2]) / luminance(rc[0], rc[1], rc[2]).max(1e-4);
|
||||
[0, 1, 2].map(|k| fg[k] + (rc[k] * l - fg[k]) * amt)
|
||||
}
|
||||
_ => fg,
|
||||
}
|
||||
};
|
||||
fg = apply_replace(fg, replace, rc, raised);
|
||||
fg = apply_replace(fg, in_replace, in_rc, raised_in);
|
||||
if fg_cc {
|
||||
fg = correct(fg, fs, fc, fbr);
|
||||
}
|
||||
let edge = edges.as_ref().map_or(0.0, |e| e[i]);
|
||||
if edge > 0.0 {
|
||||
let ce = correct(fg, es, ec, ebr);
|
||||
fg = [0, 1, 2].map(|k| fg[k] + (ce[k] - fg[k]) * edge);
|
||||
}
|
||||
let grey = |v: f32| [v, v, v, 1.0];
|
||||
*px = match view {
|
||||
1 => grey(src.data[i][3]),
|
||||
2 => {
|
||||
let mut cs = neutralise(c, dbias);
|
||||
let s2 = screen_diff(cs, pi, o1, o2, bal).max(0.0);
|
||||
cs[pi] -= s2;
|
||||
if fg_cc {
|
||||
cs = correct(cs, fs, fc, fbr);
|
||||
}
|
||||
[cs[0], cs[1], cs[2], 1.0]
|
||||
}
|
||||
3 => grey(edge),
|
||||
4 => grey(sm),
|
||||
5 => grey(inside.as_ref().map_or(0.0, |p| p.data[i])),
|
||||
6 => grey(outside.as_ref().map_or(0.0, |p| p.data[i])),
|
||||
7 => grey(a),
|
||||
8 => {
|
||||
let v = if a <= 1e-3 {
|
||||
0.0
|
||||
} else if a >= 0.999 {
|
||||
1.0
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
grey(v)
|
||||
}
|
||||
9 => premul(intermediate, sm),
|
||||
_ => {
|
||||
if cropped {
|
||||
[0.0; 4]
|
||||
} else if unpremultiply {
|
||||
premul(fg, a)
|
||||
} else {
|
||||
// Keylight's premultiplied output read as straight colour.
|
||||
premul(fg.map(|v| v * a), a)
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
let pp = |id: &'static str, name: &'static str, v: (Value, ParamUi)| p(id, name, v.0, v.1);
|
||||
let cc = |id: &'static str, name: &'static str| p(id, name, num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1));
|
||||
vec![EffectSpec {
|
||||
id: "ec.keying.keylight",
|
||||
name: "Key Light",
|
||||
category: "Keying",
|
||||
params: vec![
|
||||
p("view", "View", Value::Enum(10), popup(&VIEWS)),
|
||||
p("unpremultiplyResult", "Unpremultiply Result", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("screenColour", "Screen Colour", col(0.0, 1.0, 0.0), ParamUi::Color),
|
||||
p("screenGain", "Screen Gain", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)),
|
||||
p("screenBalance", "Screen Balance", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
p("despillBias", "Despill Bias", col(0.5, 0.5, 0.5), ParamUi::Color),
|
||||
p("alphaBias", "Alpha Bias", col(0.5, 0.5, 0.5), ParamUi::Color),
|
||||
p("lockBiasesTogether", "Lock Biases Together", Value::Bool(true), ParamUi::Checkbox),
|
||||
p("screenPreblur", "Screen Pre-blur", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 1)),
|
||||
// Screen Matte
|
||||
pp("clipBlack", "Clip Black", pct(0.0)),
|
||||
pp("clipWhite", "Clip White", pct(100.0)),
|
||||
p("clipRollback", "Clip Rollback", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("screenShrinkGrow", "Screen Shrink/Grow", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 1)),
|
||||
p("screenSoftness", "Screen Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("screenDespotBlack", "Screen Despot Black", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("screenDespotWhite", "Screen Despot White", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("replaceMethod", "Replace Method", Value::Enum(3), popup(&REPLACE)),
|
||||
p("replaceColour", "Replace Colour", col(0.5, 0.5, 0.5), ParamUi::Color),
|
||||
// Inside Mask
|
||||
p("insideMask", "Inside Mask", Value::Enum(0), mask_popup()),
|
||||
p("insideMaskSoftness", "Inside Mask Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("invertInsideMask", "Invert", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("insideReplaceMethod", "Inside Replace Method", Value::Enum(1), popup(&REPLACE)),
|
||||
p("insideReplaceColour", "Inside Replace Colour", col(0.5, 0.5, 0.5), ParamUi::Color),
|
||||
p("sourceAlpha", "Source Alpha", Value::Enum(2), popup(&["Ignore", "Add to Inside Mask", "Normal"])),
|
||||
// Outside Mask
|
||||
p("outsideMask", "Outside Mask", Value::Enum(0), mask_popup()),
|
||||
p("outsideMaskSoftness", "Outside Mask Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("invertOutsideMask", "Invert Outside Mask", Value::Bool(false), ParamUi::Checkbox),
|
||||
// Foreground Colour Correction
|
||||
p("enableColourCorrection", "Enable Colour Correction", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("saturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
cc("contrast", "Contrast"),
|
||||
cc("brightness", "Brightness"),
|
||||
// Edge Colour Correction
|
||||
p("enableEdgeColourCorrection", "Enable Edge Colour Correction", Value::Bool(false), ParamUi::Checkbox),
|
||||
pp("edgeHardness", "Edge Hardness", pct(0.0)),
|
||||
p("edgeSoftness", "Edge Softness", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("edgeGrow", "Edge Grow", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||||
p("edgeSaturation", "Edge Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
|
||||
cc("edgeContrast", "Edge Contrast"),
|
||||
cc("edgeBrightness", "Edge Brightness"),
|
||||
// Source Crops
|
||||
pp("cropLeft", "Left", pct(0.0)),
|
||||
pp("cropRight", "Right", pct(0.0)),
|
||||
pp("cropTop", "Top", pct(0.0)),
|
||||
pp("cropBottom", "Bottom", pct(0.0)),
|
||||
],
|
||||
render: key_light,
|
||||
gpu: false,
|
||||
float: true,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, MaskShape, run_fx};
|
||||
|
||||
/// Green screen with a red square (fg) and a half-transparent mix strip.
|
||||
fn plate() -> Image {
|
||||
crate::util::gen_image(40, 30, |x, y| {
|
||||
if (10..20).contains(&x) && (10..20).contains(&y) {
|
||||
[0.8, 0.2, 0.1, 1.0]
|
||||
} else if (25..30).contains(&x) {
|
||||
// 50 % red over green.
|
||||
[0.4, 0.6, 0.05, 1.0]
|
||||
} else {
|
||||
[0.1, 0.9, 0.1, 1.0]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn green() -> Value {
|
||||
col(0.1, 0.9, 0.1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_the_screen_and_keeps_the_foreground() {
|
||||
let out = run_fx("ec.keying.keylight", &[("screenColour", green())], plate(), 0.0, EffectEnv::default());
|
||||
let bg = out.img.get(2, 2);
|
||||
let fg = out.img.get(15, 15);
|
||||
let mix = out.img.get(27, 5);
|
||||
assert!(bg[3] < 0.02, "{bg:?}");
|
||||
assert!(fg[3] > 0.98 && (fg[0] - 0.8).abs() < 0.05, "{fg:?}");
|
||||
assert!(mix[3] > 0.3 && mix[3] < 0.7, "{mix:?}");
|
||||
// The recovered edge colour has the green removed (no spill).
|
||||
let (c, _) = unpremul(mix);
|
||||
assert!(c[1] <= c[0] + 0.05, "{c:?}");
|
||||
// Deterministic.
|
||||
let again = run_fx("ec.keying.keylight", &[("screenColour", green())], plate(), 0.0, EffectEnv::default());
|
||||
assert_eq!(out.img.data, again.img.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_views_and_masks() {
|
||||
// Clip White 60 %: the half-transparent strip becomes solid.
|
||||
let out = run_fx("ec.keying.keylight", &[("screenColour", green()), ("clipWhite", num(40.0))], plate(), 0.0, EffectEnv::default());
|
||||
assert!(out.img.get(27, 5)[3] > 0.99);
|
||||
// Screen Matte and Status views are grey-scale and opaque.
|
||||
let sm = run_fx("ec.keying.keylight", &[("screenColour", green()), ("view", Value::Enum(4))], plate(), 0.0, EffectEnv::default());
|
||||
assert!(sm.img.get(2, 2)[0] < 0.02 && sm.img.get(15, 15)[0] > 0.98 && sm.img.get(2, 2)[3] == 1.0);
|
||||
let st = run_fx("ec.keying.keylight", &[("screenColour", green()), ("view", Value::Enum(8))], plate(), 0.0, EffectEnv::default());
|
||||
assert_eq!(st.img.get(27, 5)[0], 0.5);
|
||||
// Source view is the identity.
|
||||
let src = run_fx("ec.keying.keylight", &[("view", Value::Enum(0))], plate(), 0.0, EffectEnv::default());
|
||||
assert_eq!(src.img.data, plate().data);
|
||||
// Inside mask forces opacity; outside mask forces transparency.
|
||||
let sq = |x0: f64, y0: f64, x1: f64, y1: f64| MaskShape {
|
||||
name: "m".into(),
|
||||
points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]],
|
||||
closed: true,
|
||||
inverted: false,
|
||||
};
|
||||
let masks = [sq(0.0, 0.0, 6.0, 6.0), sq(12.0, 12.0, 18.0, 18.0)];
|
||||
let env = EffectEnv { masks: &masks, ..Default::default() };
|
||||
let m = run_fx("ec.keying.keylight", &[("screenColour", green()), ("insideMask", Value::Enum(1)), ("outsideMask", Value::Enum(2))], plate(), 0.0, env);
|
||||
assert!(m.img.get(2, 2)[3] > 0.99, "{:?}", m.img.get(2, 2));
|
||||
assert!(m.img.get(15, 15)[3] < 0.01);
|
||||
// Screen gain above 100 % keys more of the strip.
|
||||
let g = run_fx("ec.keying.keylight", &[("screenColour", green()), ("screenGain", num(150.0))], plate(), 0.0, EffectEnv::default());
|
||||
assert!(g.img.get(27, 5)[3] < out.img.get(27, 5)[3]);
|
||||
assert_eq!(crate::lookup("Keylight (1.2)").map(|s| s.id), Some("ec.keying.keylight"));
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,10 @@ mod generate2;
|
||||
mod generate3;
|
||||
mod keying;
|
||||
mod keying2;
|
||||
pub mod keylight;
|
||||
mod matte;
|
||||
mod misc;
|
||||
pub mod mocha_shape;
|
||||
mod noise;
|
||||
mod noise2;
|
||||
mod obsolete;
|
||||
@@ -360,6 +362,8 @@ pub fn registry() -> &'static [EffectSpec] {
|
||||
v.extend(vr::specs());
|
||||
v.extend(blur3::specs());
|
||||
v.extend(distort4::specs());
|
||||
v.extend(keylight::specs());
|
||||
v.extend(mocha_shape::specs());
|
||||
v.extend(time_fx::specs());
|
||||
v.extend(audio_fx::specs());
|
||||
v.extend(paint::specs());
|
||||
@@ -380,7 +384,10 @@ pub fn find(id: &str) -> Option<&'static EffectSpec> {
|
||||
|
||||
/// Find by id or (case-insensitive) display name.
|
||||
pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> {
|
||||
find(name_or_id).or_else(|| registry().iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id)))
|
||||
find(name_or_id).or_else(|| registry().iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id))).or_else(|| {
|
||||
// After Effects' third-party display names we register under a generic name.
|
||||
keylight::KEYLIGHT_ALIASES.iter().any(|a| a.eq_ignore_ascii_case(name_or_id)).then(|| find("ec.keying.keylight")).flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// A parameter's default value on a layer of `layer_size` (point defaults are fractions of the
|
||||
@@ -478,6 +485,7 @@ pub const TIME_DEPENDENT: &[&str] = &[
|
||||
"ec.vr.digitalglitch",
|
||||
"ec.blur.camerashakedeblur",
|
||||
"ec.distort.rollingshutterrepair",
|
||||
"ec.obsolete.mochashape",
|
||||
];
|
||||
|
||||
/// See [`TIME_DEPENDENT`].
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Obsolete ▸ mocha shape: renders planar-tracked roto shapes as a matte.
|
||||
//!
|
||||
//! Mocha's own clipboard / export formats are not publicly specified, so the effect reads shape
|
||||
//! data in a small documented JSON format of ours (in the hidden `shapeData` parameter, either the
|
||||
//! JSON itself or a path to a `.json` file), which converters or agents can produce:
|
||||
//!
|
||||
//! ```json
|
||||
//! {"shapes": [{"name": "Shape 1", "closed": true, "inverted": false, "feather": 2.0,
|
||||
//! "points": [[x, y], …],
|
||||
//! "frames": [{"time": 0.0, "points": [[x, y], …]}, {"time": 1.0, "points": […]}]}]}
|
||||
//! ```
|
||||
//!
|
||||
//! Coordinates are layer pixels. `frames` (layer time in seconds, linearly interpolated between
|
||||
//! keys with the same vertex count, held outside) override the static `points`. Shapes combine
|
||||
//! with Blend Mode (add / subtract / intersect / difference) into a coverage matte that is used
|
||||
//! per Render Type: Shape Cutout (layer × matte), Color Composite (shape colour over the layer)
|
||||
//! or Color Shape Cutout (shape colour inside the matte only).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::util::{Plane, gauss_plane, point_in_poly};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MochaShape {
|
||||
pub name: String,
|
||||
pub closed: bool,
|
||||
pub inverted: bool,
|
||||
pub feather: f64,
|
||||
pub points: Vec<[f64; 2]>,
|
||||
/// (layer time, points), sorted by time.
|
||||
pub frames: Vec<(f64, Vec<[f64; 2]>)>,
|
||||
}
|
||||
|
||||
impl MochaShape {
|
||||
/// Outline at layer time `t`.
|
||||
pub fn at(&self, t: f64) -> Vec<[f64; 2]> {
|
||||
if self.frames.is_empty() {
|
||||
return self.points.clone();
|
||||
}
|
||||
let i = self.frames.partition_point(|f| f.0 <= t);
|
||||
if i == 0 {
|
||||
return self.frames[0].1.clone();
|
||||
}
|
||||
if i == self.frames.len() {
|
||||
return self.frames[i - 1].1.clone();
|
||||
}
|
||||
let (a, b) = (&self.frames[i - 1], &self.frames[i]);
|
||||
if a.1.len() != b.1.len() {
|
||||
return a.1.clone();
|
||||
}
|
||||
let u = ((t - a.0) / (b.0 - a.0).max(1e-12)).clamp(0.0, 1.0);
|
||||
a.1.iter().zip(&b.1).map(|(p, q)| [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u]).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn pts(v: &serde_json::Value) -> Vec<[f64; 2]> {
|
||||
v.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|p| {
|
||||
let p = p.as_array()?;
|
||||
Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Parse shape data JSON (see the module docs). `None` when it is not valid.
|
||||
pub fn parse_shapes(text: &str) -> Option<Vec<MochaShape>> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let list = v.get("shapes").and_then(|s| s.as_array()).or_else(|| v.as_array())?;
|
||||
let mut out = vec![];
|
||||
for s in list {
|
||||
let mut frames: Vec<(f64, Vec<[f64; 2]>)> = s
|
||||
.get("frames")
|
||||
.and_then(|f| f.as_array())
|
||||
.map(|a| a.iter().filter_map(|f| Some((f.get("time")?.as_f64()?, pts(f.get("points")?)))).collect())
|
||||
.unwrap_or_default();
|
||||
frames.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
out.push(MochaShape {
|
||||
name: s.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string(),
|
||||
closed: s.get("closed").and_then(|c| c.as_bool()).unwrap_or(true),
|
||||
inverted: s.get("inverted").and_then(|c| c.as_bool()).unwrap_or(false),
|
||||
feather: s.get("feather").and_then(|c| c.as_f64()).unwrap_or(0.0).max(0.0),
|
||||
points: s.get("points").map(pts).unwrap_or_default(),
|
||||
frames,
|
||||
});
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
type ShapeCache = Mutex<HashMap<String, Option<Arc<Vec<MochaShape>>>>>;
|
||||
|
||||
fn load(src: &str) -> Option<Arc<Vec<MochaShape>>> {
|
||||
let s = src.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
static CACHE: OnceLock<ShapeCache> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
if let Some(v) = cache.lock().ok().and_then(|c| c.get(src).cloned()) {
|
||||
return v;
|
||||
}
|
||||
let parsed =
|
||||
if s.starts_with('{') || s.starts_with('[') { parse_shapes(s) } else { std::fs::read_to_string(s).ok().and_then(|t| parse_shapes(&t)) }.map(Arc::new);
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
if c.len() > 64 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(src.to_string(), parsed.clone());
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
fn mocha_shape(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let Some(shapes) = load(ctx.params.s("shapeData")) else { return b };
|
||||
let (w, h) = (b.img.width as usize, b.img.height as usize);
|
||||
let blend = ctx.params.e("blendMode");
|
||||
let inv = 1.0 / b.scale.max(1e-9);
|
||||
let mut matte: Option<Plane> = None;
|
||||
for s in shapes.iter() {
|
||||
let outline = s.at(ctx.time);
|
||||
if outline.len() < 3 || !s.closed {
|
||||
continue;
|
||||
}
|
||||
let mut cov = Plane::new(w, h);
|
||||
cov.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
let lx = (x as f64 + 0.5 - b.offset[0]) * inv;
|
||||
let ly = (y as f64 + 0.5 - b.offset[1]) * inv;
|
||||
*v = if point_in_poly(&outline, lx, ly) != s.inverted { 1.0 } else { 0.0 };
|
||||
}
|
||||
});
|
||||
if s.feather > 0.0 {
|
||||
let f = s.feather * b.scale * 0.5;
|
||||
cov = gauss_plane(&cov, f, f);
|
||||
}
|
||||
matte = Some(match matte {
|
||||
None => cov,
|
||||
Some(m) => m.zip_map(&cov, |a, c| match blend {
|
||||
1 => a * (1.0 - c),
|
||||
2 => a * c,
|
||||
3 => (a - c).abs(),
|
||||
_ => a + c - a * c,
|
||||
}),
|
||||
});
|
||||
}
|
||||
let Some(mut m) = matte else { return b };
|
||||
if ctx.params.b("invert") {
|
||||
m = m.map(|v| 1.0 - v);
|
||||
}
|
||||
let kind = ctx.params.e("renderType");
|
||||
let c = ctx.params.color("shapeColour");
|
||||
let op = (ctx.params.f("opacity") / 100.0) as f32;
|
||||
let edge = ctx.params.b("renderEdgeWidth");
|
||||
b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, &k)| {
|
||||
let k = if edge { (1.0 - (2.0 * k - 1.0).abs()).clamp(0.0, 1.0) } else { k.clamp(0.0, 1.0) };
|
||||
match kind {
|
||||
1 => {
|
||||
let a = k * op;
|
||||
let s = [c[0] * a, c[1] * a, c[2] * a, a];
|
||||
*px = [s[0] + px[0] * (1.0 - a), s[1] + px[1] * (1.0 - a), s[2] + px[2] * (1.0 - a), s[3] + px[3] * (1.0 - a)];
|
||||
}
|
||||
2 => {
|
||||
let a = k * op;
|
||||
*px = [c[0] * a, c[1] * a, c[2] * a, a];
|
||||
}
|
||||
_ => {
|
||||
let a = 1.0 - (1.0 - k) * op;
|
||||
*px = px.map(|v| v * a);
|
||||
}
|
||||
}
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<EffectSpec> {
|
||||
vec![EffectSpec {
|
||||
id: "ec.obsolete.mochashape",
|
||||
name: "mocha shape",
|
||||
category: "Obsolete",
|
||||
params: vec![
|
||||
p("blendMode", "Blend mode", Value::Enum(0), popup(&["Add", "Subtract", "Intersect", "Difference"])),
|
||||
p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("renderEdgeWidth", "Render edge width", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("renderType", "Render type", Value::Enum(0), popup(&["Shape cutout", "Color composite", "Color shape cutout"])),
|
||||
p("shapeColour", "Shape colour", col(1.0, 1.0, 1.0), ParamUi::Color),
|
||||
p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)),
|
||||
// JSON shape data (see the module docs) or a path to a .json file.
|
||||
p("shapeData", "Shape data", Value::Str(String::new()), ParamUi::Hidden),
|
||||
],
|
||||
render: mocha_shape,
|
||||
gpu: false,
|
||||
float: true,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{EffectEnv, Image, run_fx};
|
||||
|
||||
const DATA: &str = r#"{"shapes":[{"name":"S","closed":true,"points":[[2,2],[10,2],[10,10],[2,10]],
|
||||
"frames":[{"time":0,"points":[[2,2],[10,2],[10,10],[2,10]]},{"time":1,"points":[[12,2],[20,2],[20,10],[12,10]]}]}]}"#;
|
||||
|
||||
#[test]
|
||||
fn parses_and_interpolates() {
|
||||
let s = parse_shapes(DATA).unwrap();
|
||||
assert_eq!(s.len(), 1);
|
||||
assert_eq!(s[0].at(0.5)[0], [7.0, 2.0]);
|
||||
assert_eq!(s[0].at(5.0)[0], [12.0, 2.0]);
|
||||
assert!(parse_shapes("not json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuts_out_the_shape_over_time() {
|
||||
let img = Image::filled(24, 12, [1.0, 0.0, 0.0, 1.0]);
|
||||
let a = run_fx("ec.obsolete.mochashape", &[("shapeData", Value::Str(DATA.into()))], img.clone(), 0.0, EffectEnv::default());
|
||||
assert_eq!(a.img.get(5, 5)[3], 1.0);
|
||||
assert_eq!(a.img.get(15, 5)[3], 0.0);
|
||||
let b = run_fx("ec.obsolete.mochashape", &[("shapeData", Value::Str(DATA.into()))], img.clone(), 1.0, EffectEnv::default());
|
||||
assert_eq!(b.img.get(5, 5)[3], 0.0);
|
||||
assert_eq!(b.img.get(15, 5)[3], 1.0);
|
||||
// Color shape cutout fills with the shape colour; no data = pass-through.
|
||||
let c = run_fx(
|
||||
"ec.obsolete.mochashape",
|
||||
&[("shapeData", Value::Str(DATA.into())), ("renderType", Value::Enum(2)), ("shapeColour", col(0.0, 1.0, 0.0))],
|
||||
img.clone(),
|
||||
0.0,
|
||||
EffectEnv::default(),
|
||||
);
|
||||
assert_eq!(c.img.get(5, 5), [0.0, 1.0, 0.0, 1.0]);
|
||||
let none = run_fx("ec.obsolete.mochashape", &[], img.clone(), 0.0, EffectEnv::default());
|
||||
assert_eq!(none.img.data, img.data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user