diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index a006647c..c72071d9 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -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()); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 9da455f0..b0c4bd47 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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 { + 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 { 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, diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 876f376d..c60fc0f5 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -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 } diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index 6a323ffe..0b4f4186 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -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)); diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs index 4476945f..9c422200 100644 --- a/crates/ui-egui/src/panels/menu_dialogs.rs +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -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); diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index 742c16e7..e24f11b1 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -183,8 +183,15 @@ pub fn item_interactions( actions.push(("project.select".into(), json!({"items": sel.iter().map(|i| i.0).collect::>()}))); } 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); diff --git a/crates/ui-egui/tests/menus_ui.rs b/crates/ui-egui/tests/menus_ui.rs index 881e91b2..55c42268 100644 --- a/crates/ui-egui/tests/menus_ui.rs +++ b/crates/ui-egui/tests/menus_ui.rs @@ -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()); +} diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index ea87e297..6188afea 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -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");