mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M5.16: Key Light: keyer eyedroppers pick from the effect input (effect.pickColor); "keylight" search
- effect.pickColor {effect, param|prop, x, y (layer px), average?}: the colour of the effect's
input at a point, set on the parameter (one undo step). Keying effects' Effect Controls
eyedroppers use it, since the shown frame is already keyed; Ctrl/Cmd+click averages 5x5.
- effects::aliases / name_matches: Effects & Presets and effect.list (MCP list_effects) find
Key Light by After Effects' name ("Keylight (1.2)").
- End-to-end test: a green-screen plate with an uneven screen, spill and soft edges keyed via
effect.apply "Keylight (1.2)", pick, Clip Black / White and a render; UI eyedropper test.
- Docs: parity, agents (green-screen recipe).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
47f2a5a3e6
commit
73b2607c9a
10 files changed
+272
-10
No files matched your search
@@ -610,6 +610,9 @@ mod tests {
|
||||
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"));
|
||||
// Searches find it by After Effects' name as well as its own.
|
||||
let spec = crate::find("ec.keying.keylight").unwrap();
|
||||
assert!(crate::name_matches(spec, "keylight") && crate::name_matches(spec, "key light") && !crate::name_matches(spec, "blur"));
|
||||
}
|
||||
|
||||
/// Instances saved with the flat layout get their parameters moved into the twirl-downs.
|
||||
|
||||
@@ -504,14 +504,25 @@ pub fn categories() -> Vec<&'static str> {
|
||||
v
|
||||
}
|
||||
|
||||
/// After Effects' third-party display names of effects we register under a generic name.
|
||||
const ALIASES: &[(&str, &[&str])] = &[("ec.keying.keylight", keylight::KEYLIGHT_ALIASES)];
|
||||
|
||||
/// Other names effect `id` answers to (in [`lookup`] and searches).
|
||||
pub fn aliases(id: &str) -> &'static [&'static str] {
|
||||
ALIASES.iter().find(|(i, _)| *i == id).map_or(&[], |(_, a)| a)
|
||||
}
|
||||
|
||||
/// Search (Effects & Presets, `effect.list`): the display name or one of its [`aliases`]
|
||||
/// contains `query` (lowercase).
|
||||
pub fn name_matches(spec: &EffectSpec, query: &str) -> bool {
|
||||
std::iter::once(spec.name).chain(aliases(spec.id).iter().copied()).any(|n| n.to_lowercase().contains(query))
|
||||
}
|
||||
|
||||
/// Find by id or (case-insensitive) display name.
|
||||
pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> {
|
||||
find(name_or_id)
|
||||
.or_else(|| all().into_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()
|
||||
})
|
||||
.or_else(|| ALIASES.iter().find(|(_, names)| names.iter().any(|a| a.eq_ignore_ascii_case(name_or_id))).and_then(|(id, _)| find(id)))
|
||||
.or_else(|| {
|
||||
// Display names of earlier versions.
|
||||
migrate::EFFECT_NAME_ALIASES.iter().find(|(old, _)| old.eq_ignore_ascii_case(name_or_id)).and_then(|(_, id)| find(id))
|
||||
|
||||
@@ -4,7 +4,7 @@ use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{GroupKind, LayerId, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layers_p, str_p};
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_layers, layer_mut, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// `effect.plugins.list`: the registered plug-in effects (WebAssembly or Rust).
|
||||
@@ -132,6 +132,52 @@ fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::It
|
||||
Ok((cid, lid, g.uid))
|
||||
}
|
||||
|
||||
/// A colour parameter's eyedropper on a keyer (Effect Controls): the colour of the effect's input
|
||||
/// (the layer's pixels before this effect, so a keyed screen can still be picked) at a
|
||||
/// layer-space point, set on the parameter. `average` takes the 5 × 5 pixels around it.
|
||||
fn pick_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "effect.pickColor";
|
||||
let (cid, lid, fx_uid) = find_fx(s, p, cmd)?;
|
||||
let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) };
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?;
|
||||
let index = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let g = fx.groups().nth(index).ok_or_else(|| bad(cmd, "no such effect"))?;
|
||||
let prop = match (p.get("prop").and_then(Value::as_u64), str_p(p, "param")) {
|
||||
(Some(uid), _) => g.find(uid),
|
||||
(None, Some(path)) => g.prop(path),
|
||||
_ => return Err(bad(cmd, "missing `param` (e.g. `screenColour`) or `prop` (uid)")),
|
||||
}
|
||||
.filter(|pr| pr.ui == effectcraft_project::ParamUi::Color)
|
||||
.ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?;
|
||||
let t = super::time_p(s, p, Some(comp));
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = Some(&s.layer_cache);
|
||||
let buf = r.layer_input(&ctx, layer, index).ok_or_else(|| bad(cmd, "this layer has no pixels"))?;
|
||||
let (px, py) = buf.to_px([x, y]);
|
||||
let (cx, cy) = (px.floor() as i64, py.floor() as i64);
|
||||
let rad = if b_p(p, "average") == Some(true) { 2 } else { 0 };
|
||||
let mut sum = [0.0f32; 4];
|
||||
for yy in cy.saturating_sub(rad)..=cy.saturating_add(rad) {
|
||||
for xx in cx.saturating_sub(rad)..=cx.saturating_add(rad) {
|
||||
// Outside the layer reads as transparent.
|
||||
let c = buf.img.get(xx, yy);
|
||||
(0..4).for_each(|i| sum[i] += c[i]);
|
||||
}
|
||||
}
|
||||
if sum[3] <= 1e-6 {
|
||||
return Err(bad(cmd, "nothing to sample there (transparent or outside the layer)"));
|
||||
}
|
||||
// Straight colour of the (averaged) premultiplied pixels.
|
||||
let color: Vec<f64> = (0..3).map(|i| (sum[i] / sum[3]).clamp(0.0, 1.0) as f64).chain([1.0]).collect();
|
||||
let uid = prop.uid;
|
||||
s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": color}))?;
|
||||
Ok(json!({"prop": uid, "color": color}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let fx = effects_p(s, p, "effect.remove")?;
|
||||
@@ -355,11 +401,11 @@ fn last(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
|
||||
let filter = str_p(p, "filter").map(str::to_lowercase);
|
||||
let v: Vec<Value> = effectcraft_effects::all()
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f))
|
||||
filter.as_ref().is_none_or(|f| effectcraft_effects::name_matches(e, f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f))
|
||||
})
|
||||
.map(|e| {
|
||||
json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::<Vec<_>>()})
|
||||
@@ -371,6 +417,15 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("effect.apply", "Apply Effect", [], None, "{effect: id|name (e.g. Gaussian Blur), layers?}", has_layers, apply),
|
||||
cmd!(
|
||||
"effect.pickColor",
|
||||
"Pick Effect Colour",
|
||||
[],
|
||||
None,
|
||||
"{layer?, effect?: index|uid|name, param: id (e.g. screenColour) | prop: uid, x, y (layer px), average?: bool (5×5), time?} — the colour of the effect's input there",
|
||||
has_layers,
|
||||
pick_color
|
||||
),
|
||||
cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last),
|
||||
cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all),
|
||||
cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove),
|
||||
|
||||
@@ -954,6 +954,8 @@ mod tests_fidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_frame_export;
|
||||
#[cfg(test)]
|
||||
mod tests_keylight;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
#[cfg(test)]
|
||||
mod tests_m137;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Key Light end to end: a green-screen plate (uneven screen, a subject with green spill and a
|
||||
//! soft edge) keyed through the commands an agent uses: `effect.apply` by its After Effects name,
|
||||
//! `effect.pickColor` on the screen (the effect's input, as the eyedropper does) and a render.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::{FootageSource, RenderOpts};
|
||||
|
||||
const W: u32 = 96;
|
||||
const H: u32 = 64;
|
||||
const SKIN: [f32; 3] = [0.86, 0.62, 0.52];
|
||||
|
||||
/// The plate's straight colour and alpha (opaque everywhere) at a pixel.
|
||||
fn plate(x: u32, y: u32) -> [f32; 3] {
|
||||
// Screen: a slightly uneven, noisy green.
|
||||
let noise = (((x * 7919 + y * 104_729) % 97) as f32 / 97.0 - 0.5) * 0.02;
|
||||
let screen = [0.18 + noise, 0.66 + (x as f32 / W as f32 - 0.5) * 0.04 + noise, 0.30 - noise];
|
||||
let r = ((x as f32 + 0.5 - 48.0).powi(2) + (y as f32 + 0.5 - 32.0).powi(2)).sqrt();
|
||||
// Subject: skin, with green spill in its outer 3 px and a 3 px soft edge.
|
||||
let mut subject = SKIN;
|
||||
if r > 13.0 {
|
||||
subject[1] += 0.12;
|
||||
}
|
||||
let a = ((19.0 - r) / 3.0).clamp(0.0, 1.0);
|
||||
[0, 1, 2].map(|i| subject[i] * a + screen[i] * (1.0 - a))
|
||||
}
|
||||
|
||||
struct Plate;
|
||||
|
||||
impl FootageSource for Plate {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
let mut img = Image::new(W, H);
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
let c = plate(x, y);
|
||||
img.set(x, y, [c[0], c[1], c[2], 1.0]);
|
||||
}
|
||||
}
|
||||
Some(Arc::new(img))
|
||||
}
|
||||
}
|
||||
|
||||
struct Probe;
|
||||
impl crate::Importer for Probe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
Ok(Footage {
|
||||
path: path.into(),
|
||||
kind: FootageKind::Still,
|
||||
width: W,
|
||||
height: H,
|
||||
has_video: true,
|
||||
frame_rate: FrameRate::from_f64(24.0),
|
||||
duration: Tick::ZERO,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn keyed() -> (Session, ItemId) {
|
||||
let mut s = Session { footage: Arc::new(Plate), importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": ["/plates/greenscreen.png"]})).unwrap()["items"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Key", "width": W, "height": H, "frameRate": 24, "duration": 1})).unwrap();
|
||||
let c = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
// After Effects' name finds it; MCP's add_effect / list_effects go through the same lookup.
|
||||
let r = s.execute_checked("effect.apply", json!({"effect": "Keylight (1.2)"})).unwrap();
|
||||
assert_eq!(r["effect"], "ec.keying.keylight");
|
||||
(s, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_light_removes_a_green_screen() {
|
||||
let (mut s, c) = keyed();
|
||||
let steps = s.history.undo.len();
|
||||
// The default screen colour (pure green) already keys the screen, so the shown frame is
|
||||
// transparent there; the eyedropper samples the effect's input instead.
|
||||
let picked = s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 6, "y": 6, "average": true})).unwrap();
|
||||
let col: Vec<f64> = picked["color"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let want = plate(6, 6);
|
||||
assert!((0..3).all(|i| (col[i] - want[i] as f64).abs() < 0.02), "{col:?} vs {want:?}");
|
||||
let tree = s.execute("layer.tree", json!({})).unwrap().to_string();
|
||||
assert!(tree.contains("Screen Colour"), "{tree}");
|
||||
// One undo step.
|
||||
assert_eq!(s.history.undo.len(), steps + 1);
|
||||
// Clean the matte as one does in Screen Matte view: Clip Black for the uneven screen, Clip
|
||||
// White for the spill's partial transparency.
|
||||
for (path, v) in [("effects/#1/screenMatte/clipBlack", 10), ("effects/#1/screenMatte/clipWhite", 85)] {
|
||||
s.execute_checked("prop.set", json!({"path": path, "value": v})).unwrap();
|
||||
}
|
||||
|
||||
let img = s.render(c, Tick::ZERO, RenderOpts::default());
|
||||
let px = |x, y| img.get(x, y);
|
||||
// The whole screen goes (corners, both ends of the gradient, noise).
|
||||
for (x, y) in [(1, 1), (94, 1), (1, 62), (94, 62), (20, 32), (76, 32)] {
|
||||
assert!(px(x, y)[3] < 0.03, "screen at {x},{y}: {:?}", px(x, y));
|
||||
}
|
||||
// The subject stays, with its colour.
|
||||
let fg = px(48, 32);
|
||||
assert!(fg[3] > 0.99 && (0..3).all(|i| (fg[i] - SKIN[i]).abs() < 0.02), "{fg:?}");
|
||||
// Spill on the subject's edge is suppressed: no green cast left.
|
||||
let sp = px(48, 32 - 14);
|
||||
assert!(sp[3] > 0.99 && sp[1] <= 0.5 * (sp[0] + sp[2]) + 0.01, "spill {sp:?}");
|
||||
// The soft edge keeps partial alpha, and its recovered colour has no green fringe.
|
||||
let e = px(48, 32 - 18);
|
||||
assert!(e[3] > 0.15 && e[3] < 0.85, "edge {e:?}");
|
||||
let a = e[3];
|
||||
assert!(e[1] / a <= 0.5 * (e[0] + e[2]) / a + 0.02, "edge fringe {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_color_rejects_bad_targets_and_screen_matte_view_shows_the_key() {
|
||||
let (mut s, c) = keyed();
|
||||
// Not a colour parameter / outside the layer.
|
||||
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenGain", "x": 6, "y": 6})).is_err());
|
||||
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": -50, "y": 6})).is_err());
|
||||
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 1e300, "y": -1e300, "average": true})).is_err());
|
||||
assert!(s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour"})).is_err());
|
||||
s.execute_checked("effect.pickColor", json!({"effect": 1, "param": "screenColour", "x": 90, "y": 60})).unwrap();
|
||||
// View ▸ Screen Matte: black screen (once Clip Black takes the uneven screen), white subject.
|
||||
for (path, v) in [("effects/#1/view", 4), ("effects/#1/screenMatte/clipBlack", 25)] {
|
||||
s.execute_checked("prop.set", json!({"path": path, "value": v})).unwrap();
|
||||
}
|
||||
let img = s.render(c, Tick::ZERO, RenderOpts::default());
|
||||
assert!(img.get(2, 2)[0] < 0.03 && img.get(48, 32)[0] > 0.97, "{:?} {:?}", img.get(2, 2), img.get(48, 32));
|
||||
// The Effects & Presets / list_effects search finds it by After Effects' name too.
|
||||
let list = s.execute("effect.list", json!({"filter": "Keylight (1.2)"})).unwrap();
|
||||
assert_eq!(list.as_array().map(|a| a.len()), Some(1), "{list}");
|
||||
assert_eq!(list[0]["id"], "ec.keying.keylight");
|
||||
}
|
||||
@@ -1098,7 +1098,13 @@ pub fn viewer_hook(
|
||||
app.ui.fx_pick = None;
|
||||
return;
|
||||
};
|
||||
if pick.kind == "color" {
|
||||
// Keyers pick from their input (the shown frame is already keyed): `effect.pickColor`.
|
||||
let keyer = (pick.kind == "color")
|
||||
.then(|| layer.effects()?.groups().find(|g| g.find(pick.prop).is_some()))
|
||||
.flatten()
|
||||
.filter(|g| effectcraft_engine::effects::find(&g.match_id).is_some_and(|s| s.category == "Keying"))
|
||||
.map(|g| g.uid);
|
||||
if pick.kind == "color" && keyer.is_none() {
|
||||
// GPU frames: read the shown frame back for sampling.
|
||||
app.viewer_pixels();
|
||||
}
|
||||
@@ -1113,6 +1119,7 @@ pub fn viewer_hook(
|
||||
let c = map.to_comp(hp);
|
||||
app.pointer_comp = Some([c[0] as f32, c[1] as f32]);
|
||||
if pick.kind == "color"
|
||||
&& keyer.is_none()
|
||||
&& let Some(img) = &app.viewer_image
|
||||
&& let Some(s) = fw::sample_frame(img, map.comp, c)
|
||||
{
|
||||
@@ -1129,6 +1136,15 @@ pub fn viewer_hook(
|
||||
if let Some(lp) = fw::comp_to_layer(&l2c(ectx, &layer), c) {
|
||||
actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]})));
|
||||
}
|
||||
} else if let Some(fx) = keyer {
|
||||
// Ctrl/Cmd+click averages the 5 × 5 pixels around the point.
|
||||
if let Some(lp) = fw::comp_to_layer(&l2c(ectx, &layer), c) {
|
||||
let average = ui.input(|i| i.modifiers.command);
|
||||
actions.push((
|
||||
"effect.pickColor".into(),
|
||||
json!({"layer": pick.layer, "effect": fx, "prop": pick.prop, "x": lp[0], "y": lp[1], "average": average}),
|
||||
));
|
||||
}
|
||||
} else if let Some(img) = &app.viewer_image
|
||||
&& let Some(s) = fw::sample_frame(img, map.comp, c)
|
||||
{
|
||||
|
||||
@@ -323,7 +323,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let mut y = list.min.y - scroll;
|
||||
let mut apply: Option<Apply> = None;
|
||||
let reg = effectcraft_engine::effects::all();
|
||||
let keep = |e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || e.name.to_lowercase().contains(&query));
|
||||
let keep =
|
||||
|e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || effectcraft_engine::effects::name_matches(e, &query));
|
||||
let lookup = |id: &str| reg.iter().find(|e| e.id == id);
|
||||
|
||||
// Favorites and Recently Used.
|
||||
|
||||
@@ -165,3 +165,34 @@ fn crosshair_and_eyedropper_pick_from_the_viewer() {
|
||||
}
|
||||
assert_eq!(c[3], 1.0);
|
||||
}
|
||||
|
||||
/// Key Light's Screen Colour eyedropper picks from the effect's input: the shown frame is
|
||||
/// already keyed (the default screen colour takes most of a green), so sampling it would miss.
|
||||
#[test]
|
||||
fn keyer_eyedropper_picks_the_screen_from_the_effect_input() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Key", "width": 320, "height": 180, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let screen = s.execute("layer.newSolid", json!({"name": "Screen", "color": "#30b050"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let fx = s.execute("effect.apply", json!({"layers": [screen], "effect": "Key Light"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let prop = layer(&s, screen).effects().unwrap().groups().find(|g| g.uid == fx).and_then(|g| g.get("screenColour")).map(|p| p.uid).unwrap();
|
||||
s.state.selected_layers = vec![LayerId(screen)];
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.show_panel(PanelKind::EffectControls);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
|
||||
settle(&mut h);
|
||||
h.state_mut().ui.fx_pick = Some(FxPick { kind: "color".into(), layer: screen, prop, name: "Screen Colour".into() });
|
||||
h.step();
|
||||
let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [160.0, 90.0]).unwrap();
|
||||
click(&mut h, pos);
|
||||
assert!(h.state().ui.fx_pick.is_none(), "the pick is used up");
|
||||
let v = layer(&h.state().session, screen).effects().unwrap().find(prop).unwrap().value.clone();
|
||||
let KV::Color(c) = v else { panic!("{v:?}") };
|
||||
let want = [0x30 as f64 / 255.0, 0xb0 as f64 / 255.0, 0x50 as f64 / 255.0];
|
||||
for k in 0..3 {
|
||||
assert!((c[k] - want[k]).abs() < 0.01, "{c:?} vs {want:?}");
|
||||
}
|
||||
// The picked screen keys the whole solid out.
|
||||
let s = &h.state().session;
|
||||
let img = s.render(s.active_comp_id().unwrap(), s.time(), Default::default());
|
||||
assert!(img.get(160, 90)[3] < 0.01, "{:?}", img.get(160, 90));
|
||||
}
|
||||
@@ -94,6 +94,7 @@ Short recipes; every step is one tool call.
|
||||
| Title card | `execute_command comp.new {...}` → `execute_command layer.newText {"text":"Hi","size":120}` → `render_frame {"time":0}` |
|
||||
| Animate | `add_keyframe {"layer":2,"path":"transform/scale","keys":[{"time":0,"value":[0,0]},{"time":0.5,"value":[100,100]}],"interpolation":"easyEase"}` |
|
||||
| Effect with settings | `list_effects {"filter":"glow"}` → `add_effect {"layer":2,"effect":"Glow","values":{"threshold":40,"radius":30}}` → `set_property {"layer":2,"path":"effects/#1/intensity","value":2}` |
|
||||
| Key a green screen | `add_effect {"layer":2,"effect":"Keylight (1.2)"}` → `execute_command effect.pickColor {"layer":2,"effect":1,"param":"screenColour","x":20,"y":20,"average":true}` (a screen pixel, layer space; samples the effect's input) → `set_property {"layer":2,"path":"effects/#1/screenMatte/clipBlack","value":10}` → `render_frame {"transparent":true}` |
|
||||
| Drive with an expression | `set_property {"layer":2,"path":"transform/rotation","expression":"time*90"}` |
|
||||
| Inspect a layer | `get_layer {"layer":2,"flat":true}` (every property with its `path`, value, key count) |
|
||||
| Check the result | `render_frame {"time":1.5,"max_side":640}`; in bridge mode `screenshot {"panel":"Timeline"}` |
|
||||
|
||||
+8
-1
@@ -189,7 +189,7 @@ Mocha and Cineware are third-party and not counted.
|
||||
| Blur & Sharpen | Camera-Shake Deblur, CC Radial Blur | Deblur substitutes aligned patches from sharper neighbouring frames. |
|
||||
| Audio | Compressor, Distortion, Gate | Applied in the mixdown like the other audio effects. |
|
||||
| Simulation | CC Hair, Particle Playground | Particle Playground: cannon, grid, layer exploder, layer map, gravity, repel, wall, persistent property mapper (no Particle Exploder, text particles or ephemeral mapper yet). |
|
||||
| Keying | Key Light | The full Keylight 1.2 control set under a generic name ("Keylight" is a vendor trademark); `lookup("Keylight (1.2)")` finds it. |
|
||||
| Keying | Key Light | The full Keylight 1.2 control set under a generic name ("Keylight" is a vendor trademark); `lookup("Keylight (1.2)")` and the Effects & Presets search ("keylight") find it. Keyers' colour eyedroppers sample the effect's input (`effect.pickColor`), so a screen that is already keyed can still be picked. |
|
||||
| Utility | Color Profile Converter | Our colour spaces and ACES; rendering intents (perceptual gamut compression, relative / absolute colorimetric, saturation). |
|
||||
| Matte | Mocha shape | Mocha's export format is not public: reads a documented JSON shape format instead. |
|
||||
|
||||
@@ -422,6 +422,13 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra
|
||||
spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph
|
||||
Editor keys are small squares whatever their interpolation, as in After Effects.
|
||||
|
||||
Since (M5.16): Key Light (After Effects' Keylight 1.2) is checked end to end on a green-screen
|
||||
plate with an uneven screen, spill and soft edges (`effect.apply "Keylight (1.2)"`, pick, Clip
|
||||
Black / White, render). The colour eyedroppers of Keying effects sample the effect's input
|
||||
rather than the keyed frame (Ctrl/Cmd+click averages 5 × 5 pixels); agents do the same with
|
||||
`effect.pickColor {effect, param, x, y, average?}` (layer pixels). Searching "keylight" in
|
||||
Effects & Presets, or `list_effects`, finds it.
|
||||
|
||||
## Update: M4.9–M4.11 nested comps and motion blur
|
||||
|
||||
- **Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue** and
|
||||
|
||||
Reference in new issue
Block a user