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:
parent
94a22d7a83
commit
56eaea2fa3
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue