Color Matte: pick the color on creation and change it later (#29)

- File ▸ New ▸ Color Matte… (and Project ▸ New Item) opens a New Color Matte
  dialog: color picker, name, width, height and duration. Called with params
  (CLI, control, MCP) it still creates the matte directly.
- project.matteColor {item?, color}: change a Color Matte's color (the
  selected one by default); every clip of it follows and it undoes.
  Double-clicking a Color Matte in the Project panel opens its color picker,
  as in Premiere.
- media pool: a generator's cache key now holds its parameters, so a matte
  whose color changes (or comes back with undo) is generated again instead of
  the pool serving the first one forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
carlitosteso3 2026-10-07 08:58:01 -05:00
parent 94a22d7a83
commit 56eaea2fa3
8 changed files with 173 additions and 23 deletions

View File

@ -837,3 +837,29 @@ fn subclip_source_monitor_trims_and_markers() {
s.execute("edit.undo", json!({})).unwrap();
assert!(matches!(s.project.item(sub).unwrap().kind, ItemKind::Subclip { restrict_trims: false, .. }));
}
/// #29: a Color Matte's color can be changed after it is created; the generated frames follow,
/// and undo brings the old color back (the media pool regenerates the matte, it doesn't keep
/// serving the first one).
#[test]
fn color_matte_color_changes_and_undoes() {
let mut s = demo();
let matte = s.execute("file.newColorMatte", json!({"width": 64, "height": 36, "color": "#ff0000"})).unwrap()["item"].as_u64().unwrap();
let px = |s: &Session| {
let f = s.source(ItemId(matte)).unwrap().video_frame(filmcraft_media::FrameRequest::full(Tick::ZERO)).unwrap();
f.to_rgba8()[..3].to_vec()
};
assert_eq!(px(&s), [255, 0, 0]);
let r = s.execute("project.matteColor", json!({"item": matte, "color": "#0000ff"})).unwrap();
assert_eq!(r["color"], "#0000ff");
assert_eq!(px(&s), [0, 0, 255]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(px(&s), [255, 0, 0]);
// the selected matte when no item is named; anything else is refused
s.state.project_selection = vec![ItemId(matte)];
s.execute("project.matteColor", json!({"color": "#00ff00"})).unwrap();
assert_eq!(px(&s), [0, 255, 0]);
let bars = s.execute("file.newBarsAndTone", json!({})).unwrap()["item"].as_u64().unwrap();
assert!(s.execute("project.matteColor", json!({"item": bars, "color": "#00ff00"})).is_err());
assert!(s.execute("project.matteColor", json!({"item": matte, "color": "green"})).is_err());
}

View File

@ -445,6 +445,35 @@ fn new_generator(s: &mut Session, g: Generator, name: &str, label: Label, p: &Va
Ok(json!({"item": id.0}))
}
/// `project.matteColor`: change a Color Matte's color (`item`, or the one selected in the Project
/// panel). Every clip of the matte follows; undo brings the old color back.
fn set_matte_color(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "project.matteColor";
let color = str_p(p, "color").and_then(filmcraft_color::parse_hex).ok_or_else(|| bad(CMD, "`color` must be #rrggbb"))?;
let is_matte = |s: &Session, id: ItemId| {
s.project.item(id).is_some_and(|it| matches!(&it.kind, ItemKind::Media(m) if matches!(m.media, MediaRef::Generator(Generator::ColorMatte { .. }))))
};
let item = match item_p(p, "item") {
Some(id) => id,
None => match s.state.project_selection.as_slice() {
[id] => *id,
_ => return Err(bad(CMD, "select one Color Matte (or pass `item`)")),
},
};
if !is_matte(s, item) {
return Err(bad(CMD, format!("item {} is not a Color Matte", item.0)));
}
s.edit("Color Matte Color", |pr, _| {
if let Some(ItemKind::Media(m)) = pr.item_mut(item).map(|it| &mut it.kind)
&& let MediaRef::Generator(Generator::ColorMatte { color: c }) = &mut m.media
{
*c = color;
}
Ok(())
})?;
Ok(json!({"item": item.0, "color": filmcraft_color::to_hex(color)}))
}
/// Place a project item on the timeline (drag from Project, or Insert/Overwrite from source).
pub(crate) fn place_item(
s: &mut Session,
@ -723,6 +752,7 @@ fn build() -> Vec<CommandSpec> {
let c = str_p(p, "color").and_then(filmcraft_color::parse_hex).unwrap_or([0.1, 0.1, 0.1, 1.0]);
new_generator(s, Generator::ColorMatte { color: c }, "Color Matte", Label::Lavender, p)
}),
cmd!("project.matteColor", "Color Matte Color…", [], None, r##"{"item":id?,"color":"#rrggbb"}"##, always, set_matte_color),
cmd!("file.newCountingLeader", "Universal Counting Leader…", ["File", "New"], None, "{}", always, |s, p| new_generator(
s,
Generator::CountingLeader,

View File

@ -21,24 +21,20 @@ pub fn add_generator(p: &mut Project, pool: &MediaPool, src: GeneratorSource, na
i.name = name.to_string();
i
};
let g = src.generator.clone();
let id = p.add_item(
name,
label,
ItemKind::Media(MediaClip {
media: MediaRef::Generator(g),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
bin,
);
pool.insert(id, Arc::new(src.with_name(name)));
let clip = MediaClip {
media: MediaRef::Generator(src.generator.clone()),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
};
let key = crate::media_pool::media_key(&clip);
let id = p.add_item(name, label, ItemKind::Media(clip), bin);
pool.insert_keyed(id, key, Arc::new(src.with_name(name)));
id
}

View File

@ -53,13 +53,14 @@ impl Default for MediaPool {
}
}
/// Cache key of a media clip's full-resolution reference ("" matches anything: generators and
/// sources inserted without a key).
/// Cache key of a media clip's full-resolution reference ("" matches anything: sources inserted
/// without a key). A generator's key holds its parameters, so a Color Matte whose color changes
/// (or comes back with undo) is generated again.
pub fn media_key(m: &MediaClip) -> String {
match &m.media {
MediaRef::File { path } if m.offline => format!("offline:{path}"),
MediaRef::File { path } => format!("file:{path}"),
MediaRef::Generator(_) => String::new(),
MediaRef::Generator(g) => format!("generator:{g:?}"),
}
}
@ -127,6 +128,11 @@ impl MediaPool {
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (String::new(), src));
}
/// Cache a source for an item under its [`media_key`].
pub fn insert_keyed(&self, item: ItemId, key: String, src: SharedSource) {
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (key, src));
}
/// Cache a source opened from `path` for an item.
pub fn insert_file(&self, item: ItemId, path: &str, src: SharedSource) {
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (format!("file:{path}"), src));

View File

@ -67,6 +67,8 @@ fn meta(command: &str) -> Option<(&'static str, &'static str, &'static str)> {
"file.saveAsTemplate" => ("Save as Template", "saveTemplate", "Save"),
"markers.addFlashCue" => ("Flash Cue Marker", "flashCue", "OK"),
"help.systemCompatibilityReport" => ("System Compatibility Report", "systemReport", "Close"),
"file.newColorMatte" => ("New Color Matte", "colorMatte", "OK"),
"project.matteColor" => ("Color Matte Color", "matteColor", "OK"),
_ => return None,
})
}
@ -88,6 +90,8 @@ fn dialog_command(id: &str) -> Option<&'static str> {
"sequence.transcribe" => "sequence.transcribe",
"file.saveAsTemplate" => "file.saveAsTemplate",
"markers.addFlashCue" => "markers.addFlashCue",
"file.newColorMatte" => "file.newColorMatte",
"project.matteColor" => "project.matteColor",
_ => return None,
})
}
@ -332,6 +336,24 @@ fn defaults(app: &mut FilmcraftApp, cmd: &str, id: &str) -> Result<(Value, Value
}
"file.saveAsTemplate" => (json!({"name": s.project.name}), Value::Null),
"markers.addFlashCue" => (json!({"name": "", "comment": ""}), Value::Null),
"file.newColorMatte" => {
let st = s.active_sequence().map(|q| q.settings.clone()).unwrap_or_default();
(json!({"color": "#000000", "name": "Color Matte", "width": st.width, "height": st.height, "seconds": 5.0}), Value::Null)
}
"project.matteColor" => {
let matte = match s.state.project_selection.as_slice() {
[id] => s.project.item(*id).and_then(|it| match &it.kind {
filmcraft_engine::project::ItemKind::Media(m) => match &m.media {
filmcraft_engine::project::MediaRef::Generator(filmcraft_media::Generator::ColorMatte { color }) => Some((it.id, *color)),
_ => None,
},
_ => None,
}),
_ => None,
};
let (id, color) = matte.ok_or("select one Color Matte")?;
(json!({"item": id.0, "color": filmcraft_color::to_hex(color)}), Value::Null)
}
_ => (json!({}), Value::Null),
})
}
@ -400,6 +422,21 @@ fn combo(ui: &mut egui::Ui, elems: &mut Elems, id: &str, label: &str, value: &mu
});
}
/// A color swatch that opens the color picker; the parameter is `#rrggbb`.
fn color(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, key: &str, label: &str) {
ui.horizontal(|ui| {
ui.label(label);
let c = p.get(key).and_then(Value::as_str).and_then(filmcraft_color::parse_hex).unwrap_or([0.0, 0.0, 0.0, 1.0]);
let mut rgb = [c[0], c[1], c[2]].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
let r = egui::color_picker::color_edit_button_srgb(ui, &mut rgb);
push(elems, format!("{pre}.{key}"), &r, label);
if r.changed() {
p[key] = json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]));
}
ui.label(p.get(key).and_then(Value::as_str).unwrap_or_default());
});
}
fn pairs(v: &[&str]) -> Vec<(String, String)> {
v.iter().map(|x| (x.to_string(), x.to_string())).collect()
}
@ -687,6 +724,14 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
check(ui, &mut elems, pre, p, "diarize", "Recognize when different speakers are talking");
}
"file.saveAsTemplate" => text(ui, &mut elems, pre, p, "name", "Template Name:", 240.0),
"file.newColorMatte" => {
color(ui, &mut elems, pre, p, "color", "Color:");
text(ui, &mut elems, pre, p, "name", "Name:", 220.0);
number(ui, &mut elems, pre, p, "width", "Width:", 1.0..=16384.0, " px");
number(ui, &mut elems, pre, p, "height", "Height:", 1.0..=16384.0, " px");
number(ui, &mut elems, pre, p, "seconds", "Duration:", 0.04..=36000.0, " s");
}
"project.matteColor" => color(ui, &mut elems, pre, p, "color", "Color:"),
"markers.addFlashCue" => {
text(ui, &mut elems, pre, p, "name", "Name:", 220.0);
text(ui, &mut elems, pre, p, "comment", "Comments:", 220.0);

View File

@ -183,8 +183,15 @@ pub fn item_interactions(
actions.push(("project.select".into(), json!({"items": sel.iter().map(|i| i.0).collect::<Vec<_>>()})));
}
if resp.double_clicked() {
let cmd = if matches!(kind, ItemKind::Sequence(_)) { "sequence.open" } else { "source.open" };
actions.push((cmd.into(), json!({"item": id.0})));
let matte = matches!(kind, ItemKind::Media(m) if matches!(m.media, filmcraft_engine::project::MediaRef::Generator(filmcraft_media::Generator::ColorMatte { .. })));
if matte {
// like Premiere: double-clicking a Color Matte opens its color picker
actions.push(("project.select".into(), json!({"items": [id.0]})));
actions.push(("project.matteColor".into(), json!({})));
} else {
let cmd = if matches!(kind, ItemKind::Sequence(_)) { "sequence.open" } else { "source.open" };
actions.push((cmd.into(), json!({"item": id.0})));
}
}
if drag && resp.drag_started() {
crate::panels::start_drag_item(ui, id);

View File

@ -268,3 +268,40 @@ fn clip_and_sequence_dialogs() {
d.click("properties.ok");
assert!(!d.dialog_open());
}
/// #29: File ▸ New ▸ Color Matte… asks for the color (and name, size, duration), and an existing
/// matte's color can be changed afterwards (double-click in the Project panel, or
/// `project.matteColor`), which used to leave every matte stuck at its first, dark grey color.
#[test]
fn color_matte_dialog_picks_and_changes_the_color() {
let mut d = Driver::new();
let matte_color = |d: &mut Driver, id: u64| match &d.app().session.project.item(ItemId(id)).unwrap().kind {
filmcraft_engine::project::ItemKind::Media(m) => match &m.media {
filmcraft_engine::project::MediaRef::Generator(filmcraft_media::Generator::ColorMatte { color }) => filmcraft_color::to_hex(*color),
g => panic!("not a matte: {g:?}"),
},
_ => panic!("not media"),
};
assert_eq!(d.menu("file.newColorMatte")["dialog"], "colorMatte");
for id in ["colorMatte.color", "colorMatte.name", "colorMatte.width", "colorMatte.height", "colorMatte.seconds", "colorMatte.ok"] {
assert!(d.has(id), "{id}");
}
d.ok("ui.set", json!({"menuDialog": {"color": "#ff8000", "name": "Orange"}}));
d.click("colorMatte.ok");
assert!(!d.dialog_open());
let orange = d.item_named("Orange");
assert_eq!(matte_color(&mut d, orange), "#ff8000");
// change it: the dialog starts from the current color
d.exec("project.select", json!({"items": [orange]}));
assert_eq!(d.menu("project.matteColor")["dialog"], "matteColor");
assert_eq!(d.app().ui.extras.dialog.as_ref().unwrap().params["color"], "#ff8000");
assert!(d.has("matteColor.color"));
d.ok("ui.set", json!({"menuDialog": {"color": "#2040c0"}}));
d.click("matteColor.ok");
assert_eq!(matte_color(&mut d, orange), "#2040c0");
// without a Color Matte selected there is nothing to change
let seq = d.app().session.state.active_sequence.unwrap().0;
d.exec("project.select", json!({"items": [seq]}));
assert_eq!(d.call("ui.menu.invoke", json!({"id": "project.matteColor"}))["ok"], json!(false));
assert!(!d.dialog_open());
}

View File

@ -460,6 +460,9 @@ fn footer_buttons_are_wired() {
d.wait_for("project.newItem.file.newColorMatte");
let n0 = d.app().session.project.items.len();
d.click("project.newItem.file.newColorMatte");
// the New Color Matte dialog asks for the color first (#29)
assert!(d.app().ui.extras.dialog.is_some(), "New Color Matte dialog");
d.click("colorMatte.ok");
assert_eq!(d.app().session.project.items.len(), n0 + 1);
d.click("project.button.project.delete");
assert_eq!(d.app().session.project.items.len(), n0, "Clear removes the selected new matte");