From 9ae7788f8df1ca12a05a4d2118a2680e27f148e6 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 03:32:01 +1100 Subject: [PATCH 01/23] Keep the playhead on the frame shown in the current-time field. Timecode and Frames entries were parsed as absolute counts without the composition Start Timecode or Start Numbering Frames At offset, so committing the displayed text jumped to another frame. --- crates/engine/src/commands/time.rs | 14 +++++++++++++- crates/engine/src/tests_timeline.rs | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 9ed41f47c..811976021 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -38,7 +38,19 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> let start = fr.frame_at(comp.display_start) + st.frame_start; effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0) } - None => effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0), + None => { + let parsed = effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0)?; + let relative = text.trim().starts_with('+') || text.trim().starts_with('-'); + if relative { + Ok(parsed) + } else if st.time_display == TimeDisplayStyle::Frames { + // Displayed digits are composition frame plus Start Numbering Frames At. + Ok(parsed - st.frame_start) + } else { + // Displayed timecode includes the composition Start Timecode offset. + Ok(parsed - fr.frame_at(comp.display_start)) + } + } } } diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index cdfbf8dd9..eacf3da34 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -375,6 +375,29 @@ fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() { assert_eq!(effectcraft_time::format_timecode_ae(75, fr, true), "0;00;02;15"); } +#[test] +fn time_set_keeps_the_frame_shown_in_the_current_time_field() { + let mut s = Session::default(); + s.execute( + "comp.new", + json!({"name": "Example", "width": 64, "height": 64, "frameRate": 30, "duration": 0.6, "startTimecode": "0:00:00:05"}), + ) + .unwrap(); + let r = s.execute("time.set", json!({"frame": 0})).unwrap(); + assert_eq!(r["display"], "0:00:00:05"); + let r = s.execute("time.set", json!({"timecode": "0:00:00:05"})).unwrap(); + assert_eq!((r["frame"].as_i64(), r["time"].as_f64()), (Some(0), Some(0.0))); + + s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap(); + std::sync::Arc::make_mut(&mut s.project).settings.frame_start = 1; + let r = s.execute("time.set", json!({"frame": 12})).unwrap(); + assert_eq!(r["display"], "00013"); + let r = s.execute("time.set", json!({"timecode": "00013"})).unwrap(); + assert_eq!((r["frame"].as_i64(), r["time"].as_f64()), (Some(12), Some(0.4))); + let r = s.execute("time.set", json!({"timecode": "+1"})).unwrap(); + assert_eq!(r["frame"], 13); +} + #[test] fn separate_dimensions_split_and_rejoin() { let (mut s, l) = setup(); From 4df4d9185bf53ecaedb60cfc964385f3d81cd1cd Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:41:07 +0700 Subject: [PATCH 02/23] fix(effect): expose parameter metadata in effect.list (#441) --- crates/engine/src/commands/effect.rs | 51 ++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 686846504..3515f5088 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -415,7 +415,24 @@ fn list(_: &mut Session, p: &Value) -> Result { 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::>()}) + json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| { + let mut out = json!({"id": p.id, "name": p.name, "type": p.default.kind_name(), "default": p.default.to_json()}); + match &p.ui { + effectcraft_project::ParamUi::Popup { options } => { + // Engine/MCP popup values are zero-based; JSX exposes one-based values. + out["options"] = json!(options.iter().enumerate().map(|(value, label)| json!({"value": value, "label": label})).collect::>()); + } + effectcraft_project::ParamUi::Slider { min, max, slider_min, slider_max, decimals } => { + out["min"] = json!(min); + out["max"] = json!(max); + out["sliderMin"] = json!(slider_min); + out["sliderMax"] = json!(slider_max); + out["decimals"] = json!(decimals); + } + _ => {} + } + out + }).collect::>()}) }) .collect(); Ok(json!(v)) @@ -469,7 +486,7 @@ pub fn specs() -> Vec { cmd!("effect.copy", "Copy Effects", [], None, "{layer?, effect? | effects?: [uid|name|index]} (default: the selected effects)", has_layers, copy), cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste), cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset), - crate::query!("effect.list", "List Effects", "{filter?}", list), + crate::query!("effect.list", "List Effects", "{filter?} → [{params: [{id, name, type, default, options?, min?, max?, sliderMin?, sliderMax?, decimals?}]}]; popup indices are zero-based (JSX uses one-based values)", list), crate::query!( "effect.warning", "Effect Warning", @@ -493,3 +510,33 @@ pub fn specs() -> Vec { ), ] } + +#[cfg(test)] +mod list_metadata_tests { + use super::*; + + #[test] + fn effect_list_exposes_popup_labels_and_numeric_slider_limits() { + let mut s = Session::default(); + let menus = s.execute("effect.list", json!({"filter": "ec.control.dropdown"})).unwrap(); + assert_eq!(menus.as_array().map(Vec::len), Some(1)); + let menu = &menus[0]["params"][0]; + assert_eq!(menu["type"], "enum"); + assert_eq!(menu["default"], 0); + assert_eq!(menu["options"], json!([ + {"value": 0, "label": "Item 1"}, + {"value": 1, "label": "Item 2"}, + {"value": 2, "label": "Item 3"} + ])); + let sliders = s.execute("effect.list", json!({"filter": "ec.control.slider"})).unwrap(); + assert_eq!(sliders.as_array().map(Vec::len), Some(1)); + let slider = &sliders[0]["params"][0]; + assert_eq!(slider["type"], "scalar"); + assert_eq!(slider["min"], -1_000_000.0); + assert_eq!(slider["max"], 1_000_000.0); + assert_eq!(slider["sliderMin"], 0.0); + assert_eq!(slider["sliderMax"], 100.0); + assert_eq!(slider["decimals"], 2); + assert!(slider["options"].is_null()); + } +} From 139ff9a4e3b48c5c9c8b2e52380dd263af353fb7 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 03:50:22 +1100 Subject: [PATCH 03/23] Start Linux audio preview after the first mixed samples, on a short device buffer. Pulse and PipeWire were opening a large default period and playing silence until the feeder caught up, so the picture ran while the sound started late. The stream now waits for queued samples and requests about 10 ms of buffering. --- apps/effectcraft/src/audio_out.rs | 88 +++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 9 deletions(-) diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index a7929b6d9..7c26db51f 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -10,11 +10,32 @@ use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; use std::thread::JoinHandle; +use std::time::Duration; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{FromSample, SizedSample}; use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed, AudioOutput}; +/// Device callback period: about 10 ms, clamped so Pulse/PipeWire/ALSA does not open a +/// multi-hundred-millisecond default buffer that plays as silence at the start of preview (#554). +fn preferred_buffer_frames(rate: u32) -> u32 { + (rate / 100).clamp(256, 2048) +} + +/// Wait until the feeder has mixed a block, or until preview is stopped. Starting the stream +/// before that fills the device with silence, so the first real samples are heard late (#554). +fn wait_to_play(feed: &AudioFeed, stop: &mpsc::Receiver<()>) -> bool { + loop { + if feed.queued_frames() > 0 { + return true; + } + match stop.recv_timeout(Duration::from_millis(2)) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return false, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + } +} + pub struct CpalOut { out: AudioOutput, rate: u32, @@ -131,21 +152,36 @@ impl AudioDevice for CpalOut { }; let mut cfg: cpal::StreamConfig = sup.config(); cfg.sample_rate = cpal::SampleRate(rate); - let stream = match sup.sample_format() { - cpal::SampleFormat::F32 => run::(&dev, &cfg, feed, latency, map), - cpal::SampleFormat::I16 => run::(&dev, &cfg, feed, latency, map), - cpal::SampleFormat::U16 => run::(&dev, &cfg, feed, latency, map), - cpal::SampleFormat::I32 => run::(&dev, &cfg, feed, latency, map), + cfg.buffer_size = cpal::BufferSize::Fixed(preferred_buffer_frames(rate)); + let build = |cfg: &cpal::StreamConfig| match sup.sample_format() { + cpal::SampleFormat::F32 => run::(&dev, cfg, feed.clone(), latency.clone(), map), + cpal::SampleFormat::I16 => run::(&dev, cfg, feed.clone(), latency.clone(), map), + cpal::SampleFormat::U16 => run::(&dev, cfg, feed.clone(), latency.clone(), map), + cpal::SampleFormat::I32 => run::(&dev, cfg, feed.clone(), latency.clone(), map), f => Err(format!("unsupported sample format {f:?}")), }; - let stream = match stream.and_then(|s| s.play().map(|_| s).map_err(|e| e.to_string())) { + let stream = match build(&cfg) { Ok(s) => s, - Err(e) => { - let _ = ready_tx.send(Err(e)); - return; + Err(_) => { + // Some hosts reject a fixed period; a large default still plays, just later. + cfg.buffer_size = cpal::BufferSize::Default; + match build(&cfg) { + Ok(s) => s, + Err(e) => { + let _ = ready_tx.send(Err(e)); + return; + } + } } }; let _ = ready_tx.send(Ok(())); + if !wait_to_play(&feed, &stop_rx) { + return; + } + if let Err(e) = stream.play() { + eprintln!("effectcraft: audio output: {e}"); + return; + } let _ = stop_rx.recv(); drop(stream); }) @@ -174,3 +210,37 @@ impl Drop for CpalOut { self.stop(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preferred_buffer_is_about_ten_milliseconds() { + assert_eq!(preferred_buffer_frames(48_000), 480); + assert_eq!(preferred_buffer_frames(8_000), 256); + assert_eq!(preferred_buffer_frames(192_000), 1920); + } + + #[test] + fn wait_to_play_returns_once_the_feeder_has_samples() { + let feed = Arc::new(AudioFeed::default()); + let (_tx, rx) = mpsc::channel(); + let f = feed.clone(); + let h = std::thread::spawn(move || wait_to_play(&f, &rx)); + assert_eq!(feed.queued_frames(), 0); + std::thread::sleep(Duration::from_millis(20)); + feed.push(&[0.1, 0.2]); + assert!(h.join().unwrap()); + assert_eq!(feed.queued_frames(), 1); + } + + #[test] + fn wait_to_play_stops_without_samples() { + let feed = AudioFeed::default(); + let (tx, rx) = mpsc::channel(); + let h = std::thread::spawn(move || wait_to_play(&feed, &rx)); + tx.send(()).unwrap(); + assert!(!h.join().unwrap()); + } +} From b28485cb87c54095baf17190e75cf256779c070d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:08:09 +0200 Subject: [PATCH 04/23] Interface: probe Hangul so Korean text isn't drawn as boxes (#599) --- crates/ui-egui/src/theme.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a37bead64..b12251ce0 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -339,8 +339,9 @@ const SCRIPT_PROBES: &[(char, &str)] = &[ /// Japanese-locale machine would be drawn with the Japanese face. The interface language is the /// better signal, so the probe for its script comes first — kana for Japanese, a Han character for /// the Chinese catalogs. Both scripts are registered, so file, layer and template names in the -/// other one still render; then [`SCRIPT_PROBES`]. The web build has no system fonts and installs -/// nothing. +/// other one still render. Hangul comes next, since the Japanese and Chinese faces may lack it +/// (Hiragino Sans on macOS has none); then [`SCRIPT_PROBES`]. The web build has no system fonts +/// and installs nothing. fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) -> Vec { #[cfg(target_arch = "wasm32")] { @@ -354,10 +355,13 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // language's face draws them. const KANA: (char, &str) = ('あ', "japanese-system"); const HAN: (char, &str) = ('文', "chinese-system"); + // Hangul is probed after kana and Han so their order is unchanged; a face that already covers it + // is skipped as seen. + const HANGUL: (char, &str) = ('한', "korean-system"); // Han text follows the interface language, not the operating system's locale. fonts::set_cjk_locale(cjk_locale(language)); let base = fonts::resolve("Inter", "Regular").face; - let cjk = if is_chinese(language) { [HAN, KANA] } else { [KANA, HAN] }; + let cjk = if is_chinese(language) { [HAN, KANA, HANGUL] } else { [KANA, HAN, HANGUL] }; let mut names = Vec::new(); let mut seen = Vec::new(); for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES) { @@ -597,6 +601,24 @@ mod tests { install_script_fallbacks(&mut english, "en"); } + #[test] + fn hangul_gets_a_face_when_one_is_installed() { + use effectcraft_text::fonts; + let mut fonts_def = FontDefinitions::default(); + install_script_fallbacks(&mut fonts_def, "en"); + let base = fonts::resolve("Inter", "Regular").face; + let id = fonts::fallback_for('한', base); + let face = fonts::face(id); + if !face.has_char('한') { + eprintln!("no Hangul font is installed: nothing to check here"); + return; + } + let Some(font) = face.font() else { return }; + let bytes = font.data().as_bytes(); + let covered = fonts_def.font_data.values().any(|d| d.font.as_ref() == bytes && d.index == face.info.index); + assert!(covered, "a face covering Hangul must be registered with egui"); + } + #[test] fn a_fallback_face_is_registered_at_most_once() { let mut fonts = FontDefinitions::default(); From bdae2f0fe32fe9b3343369ab45d89deac4831aee Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:10:42 +0200 Subject: [PATCH 05/23] Rove Across Time keeps roving keys off the neighbouring fixed keys' times (#532) --- crates/keyframe/src/lib.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 13814c532..805371491 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -426,7 +426,13 @@ pub fn retime_roving(keys: &mut [Keyframe], spatial: bool) -> bool { for (o, i) in (a + 1..b).enumerate() { let f = if total > 1e-9 { acc[o + 1] / total } else { (o + 1) as f64 / (b - a) as f64 }; let span = i128::from(t1.0) - i128::from(t0.0); - let retimed = i128::from(t0.0) + (span as f64 * f).round() as i128; + let mut retimed = i128::from(t0.0) + (span as f64 * f).round() as i128; + // Keep every roving key strictly between its neighbours: a zero-length segment would otherwise + // put it on a fixed key's time, and two keys can't share a time. + let (lo, hi) = (i128::from(keys[i - 1].time.0) + 1, i128::from(t1.0) - (b - i) as i128); + if lo <= hi { + retimed = retimed.clamp(lo, hi); + } let nt = Tick(retimed.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64); if nt != keys[i].time { keys[i].time = nt; @@ -817,6 +823,21 @@ mod tests { assert!(!keys[0].roving); } + #[test] + fn roving_keys_never_share_a_time_with_a_fixed_key() { + // Zero-length last segment (issue #532) and zero-length first segment. + for xs in [[0.0, 40.0, 120.0, 120.0], [0.0, 0.0, 40.0, 120.0]] { + let mut keys: Vec = xs.iter().enumerate().map(|(i, x)| Keyframe::new(s(i as f64), Value::Vec2([*x, 64.0]))).collect(); + for k in &mut keys { + k.spatial_auto = false; + } + keys[1].roving = true; + keys[2].roving = true; + retime_roving(&mut keys, true); + assert!(keys.windows(2).all(|w| w[0].time < w[1].time), "{xs:?}: {:?}", keys.iter().map(|k| k.time).collect::>()); + } + } + #[test] fn side_ease_reports_linear_and_eased_sides() { let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).eased()]; From d5f8fb54cb3c52a22f8a3e65c8b91f2a5f37727d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:11:00 +0200 Subject: [PATCH 06/23] Viewer: passive Composition viewers apply the monitor color conversion (#547) --- crates/ui-egui/src/panels/viewer_tools.rs | 13 +++-- crates/ui-egui/src/panels/viewers.rs | 36 +++++++++++- .../ui-egui/tests/viewer_passive_display.rs | 58 +++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/viewer_passive_display.rs diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 06679783e..e9a399432 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -349,6 +349,14 @@ fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32, egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect()) } +/// The display conversion's inputs, for the texture caches. +pub(crate) fn display_key(app: &EffectcraftApp, has_conversion: bool) -> String { + let opts = &app.session.state.viewer; + let custom = if opts.simulation.profile == vw::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() }; + format!("{:?}{:?}{}{custom}", has_conversion, opts.simulation, app.session.prefs.previews.display_profile) + + &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management) +} + /// Draw the frame into `comp_rect`: the rendered frame (covering the region of interest when one /// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the /// exposure. Fast Previews ▸ Wireframe draws layer outlines instead. @@ -378,10 +386,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: return; } let dc = vw::DisplayColor::of(&app.session); - // The display conversion's inputs, for the texture caches. - let custom = if opts.simulation.profile == vw::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() }; - let dc_key = format!("{:?}{:?}{}{custom}", dc.is_some(), opts.simulation, app.session.prefs.previews.display_profile) - + &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management); + let dc_key = display_key(app, dc.is_some()); let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0 && dc.is_none(); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); if snap { diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index f23fd5115..8d2351330 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -102,7 +102,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) { app.request_frame(cid, frame, scale); let key = app.frame_key(cid, frame, scale); if let Some((key, img)) = app.frames.get_for_viewer(&key, app.passive_tex.get(&id).map(|(k, _)| k)) { - set_texture(app, &ctx, id, key, img); + let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session); + let dkey = display_hash(&super::viewer_tools::display_key(app, dc.is_some())); + set_texture(app, &ctx, id, key, img, dc, dkey); } match app.passive_tex.get(&id).filter(|(k, _)| k.comp == cid.0) { Some((_, tex)) => { @@ -126,16 +128,38 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) { } /// Keep the frame viewer `id` shows in its texture. -fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: FrameKey, img: FrameImage) { +fn set_texture( + app: &mut EffectcraftApp, + ctx: &egui::Context, + id: u32, + key: FrameKey, + img: FrameImage, + dc: Option, + dkey: u64, +) { + let dc_id = egui::Id::new(("passive-viewer-display", id)); + let same_display = ctx.data(|d| d.get_temp::(dc_id)) == Some(dkey); if let Some((k, _)) = app.passive_tex.get_mut(&id) && *k == key + && same_display { // An undo or an edit elsewhere can reuse this content at a newer revision. Keep that // revision as the lower bound for subsequent intermediate edit frames. k.revision = key.revision; return; } + ctx.data_mut(|d| d.insert_temp(dc_id, dkey)); let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); + // The monitor conversion the active viewer applies (Use Display Color Management). + let img = match (img, &dc) { + (FrameImage::Cpu(img), Some(dc)) => { + let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect(); + dc.apply(&mut px); + let pixels = px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect(); + FrameImage::Cpu(Arc::new(egui::ColorImage::new(img.size, pixels))) + } + (img, _) => img, + }; let tex = match (img, app.passive_tex.remove(&id)) { (FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h)))) if h.size() == crate::frames::fitted_size(img.size, crate::frames::max_texture_side(ctx)) => @@ -165,6 +189,14 @@ fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: Fram app.passive_tex.insert(id, (key, tex)); } +/// A hash of the display conversion's inputs (see `viewer_tools::display_key`). +fn display_hash(key: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + key.hash(&mut h); + h.finish() +} + /// Give a passive viewer's texture back (GPU textures are registered with the renderer). fn free(app: &EffectcraftApp, tex: Option) { if let (Some(PassiveTexture::Gpu(id, _)), Some(rs)) = (tex, &app.wgpu) { diff --git a/crates/ui-egui/tests/viewer_passive_display.rs b/crates/ui-egui/tests/viewer_passive_display.rs new file mode 100644 index 000000000..797689af3 --- /dev/null +++ b/crates/ui-egui/tests/viewer_passive_display.rs @@ -0,0 +1,58 @@ +//! A passive Composition viewer applies the same monitor conversion as the active one (#547). +use effectcraft_engine::Session; +use effectcraft_ui_egui::{EffectcraftApp, menus, panels::viewers}; +use egui::{Color32, Context, ImageData, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use std::collections::HashMap; + +fn frames(app: &mut EffectcraftApp, ctx: &Context, textures: &mut HashMap>) { + for _ in 0..4 { + let mut output = ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(256.0, 256.0))), ..Default::default() }, |ui| { + viewers::show(app, ui, 0, Rect::from_min_size(pos2(0.0, 0.0), vec2(128.0, 256.0))); + viewers::show(app, ui, 1, Rect::from_min_size(pos2(128.0, 0.0), vec2(128.0, 256.0))); + }); + for (&id, deltas) in &output.textures_delta.set { + let Some(name) = ctx.tex_manager().read().meta(id).map(|m| m.name.clone()) else { continue }; + for delta in deltas { + let ImageData::Color(image) = &delta.image; + if delta.pos.is_none() { + textures.insert(name.clone(), image.pixels.clone()); + } + } + } + output.textures_delta.clear(); + std::thread::sleep(std::time::Duration::from_millis(100)); + } +} + +fn pixel(textures: &HashMap>, name: &str) -> [u8; 4] { + let p = &textures[name]; + p[p.len() / 2].to_array() +} + +#[test] +fn passive_viewer_matches_the_monitor_conversion() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Display test", "width": 16, "height": 16, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Color", "color": "#406080", "width": 16, "height": 16})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut app = EffectcraftApp::new(s); + let ctx = Context::default(); + menus::invoke(&mut app, &ctx, "view.newViewer", json!({})).unwrap(); + let mut textures = HashMap::new(); + app.session.execute("file.projectSettings", json!({"workingSpace": "rec2020"})).unwrap(); + app.session.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "p3"})).unwrap(); + frames(&mut app, &ctx, &mut textures); + assert_eq!(pixel(&textures, "viewer-display"), [37, 89, 122, 255]); + assert_eq!(pixel(&textures, "viewer-0"), [37, 89, 122, 255]); + // Changing which viewer is active keeps the conversion in both. + viewers::activate(&mut app, &ctx, 0); + frames(&mut app, &ctx, &mut textures); + assert_eq!(pixel(&textures, "viewer-display"), [37, 89, 122, 255]); + assert_eq!(pixel(&textures, "viewer-1"), [37, 89, 122, 255]); + // No output-to-monitor conversion needed: both show the raw frame. + app.session.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "srgb"})).unwrap(); + frames(&mut app, &ctx, &mut textures); + assert_eq!(pixel(&textures, "viewer-frame"), [0, 90, 125, 255]); + assert_eq!(pixel(&textures, "viewer-1"), [0, 90, 125, 255]); +} From ad0fc4da094f48fcfd7676c14b203da3cb62d6ca Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:10:41 +0200 Subject: [PATCH 07/23] Locked viewer: re-convert pixels when the display profile or output simulation changes (#546) --- crates/ui-egui/src/panels/viewer.rs | 6 +- crates/ui-egui/tests/ui_locked_color.rs | 81 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/ui_locked_color.rs diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9adf007f8..88995483e 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -522,10 +522,14 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo let cam = (lv.view != View3D::ActiveCamera && comp.has_3d()) .then(|| app.session.state.views3d.get(&lv.comp).cloned().unwrap_or_default().cam(lv.view, comp.width as f64, comp.height as f64).state()); let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session); + // The display conversion's inputs, keyed as in the main viewer's texture caches. + let sim = app.session.state.viewer.simulation; + let custom = if sim.profile == effectcraft_engine::viewer::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() }; + let dc_key = format!("{cam:?}{}{sim:?}{}{custom}", dc.is_some(), app.session.prefs.previews.display_profile); let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (app.session.revision, lv.comp.0, t.0, scale.to_bits(), format!("{cam:?}{}", dc.is_some())).hash(&mut h); + (app.session.revision, lv.comp.0, t.0, scale.to_bits(), dc_key).hash(&mut h); h.finish() }; let id = egui::Id::new("viewer-locked"); diff --git a/crates/ui-egui/tests/ui_locked_color.rs b/crates/ui-egui/tests/ui_locked_color.rs new file mode 100644 index 000000000..d942da111 --- /dev/null +++ b/crates/ui-egui/tests/ui_locked_color.rs @@ -0,0 +1,81 @@ +//! The split locked viewer re-converts its pixels when the display profile or the output +//! simulation changes (egui_kittest, texture uploads captured). + +use effectcraft_engine::{Session, render::RenderOpts, viewer::DisplayColor}; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::{Harness, TestRenderer}; +use serde_json::{Value, json}; +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +type Images = Rc>>; + +struct Capture(Images); + +impl TestRenderer for Capture { + fn handle_delta(&mut self, delta: &mut egui::TexturesDelta) { + for (id, ops) in std::mem::take(&mut delta.set) { + for op in ops { + let egui::ImageData::Color(image) = op.image; + if op.pos.is_none() && image.size[0] <= 256 && image.size[1] <= 256 { + self.0.borrow_mut().insert(id, (*image).clone()); + } + } + } + for id in std::mem::take(&mut delta.free) { + self.0.borrow_mut().remove(&id); + } + } + + fn render(&mut self, _: &egui::Context, _: &egui::FullOutput) -> Result { + Err("Texture inspection only".into()) + } +} + +fn invoke(h: &mut Harness<'_, EffectcraftApp>, command: &str, params: Value) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, command, params).unwrap(); + h.run_steps(3); +} + +fn locked_center(h: &Harness<'_, EffectcraftApp>, images: &Images) -> [u8; 4] { + let (_, texture) = h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::new("viewer-locked"))).unwrap(); + let images = images.borrow(); + let image = images.get(&texture.id()).unwrap(); + image.pixels[(image.size[1] / 2) * image.size[0] + image.size[0] / 2].to_array() +} + +fn expected_center(h: &mut Harness<'_, EffectcraftApp>) -> [u8; 4] { + let s = &mut h.state_mut().session; + let comp = s.active_comp_id().unwrap(); + let image = s.render(comp, s.time(), RenderOpts::default()); + let ci = effectcraft_ui_egui::frames::to_color_image(&image); + let mut pixels = vec![ci.pixels[(ci.size[1] / 2) * ci.size[0] + ci.size[0] / 2].to_array()]; + DisplayColor::of(s).unwrap().apply(&mut pixels); + pixels[0] +} + +#[test] +fn locked_viewer_follows_display_profile_and_simulation() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Color", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 64, "height": 64})).unwrap(); + s.execute("file.projectSettings", json!({"workingSpace": "srgb", "outputSpace": "rec2020", "renderer": "software"})).unwrap(); + s.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "srgb"})).unwrap(); + s.execute("view.displayColorManagement", json!({"value": true})).unwrap(); + let images = Images::default(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).renderer(Capture(images.clone())).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + invoke(&mut h, "view.splitLockedViewer", json!({})); + let first = expected_center(&mut h); + assert_eq!(locked_center(&h, &images), first); + + invoke(&mut h, "prefs.set", json!({"key": "previews.displayProfile", "value": "p3"})); + let after_display = expected_center(&mut h); + assert_ne!(after_display, first); + assert_eq!(locked_center(&h, &images), after_display); + + invoke(&mut h, "view.simulateOutput", json!({"profile": "rec709", "preserveRgb": true})); + let after_sim = expected_center(&mut h); + assert_ne!(after_sim, after_display); + assert_eq!(locked_center(&h, &images), after_sim); +} From 9cae69a3666991ec9f758295d9577d05ee5a990f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:11:04 +0200 Subject: [PATCH 08/23] Effects: CC Power Pin Unstretch reverses the pin mapping (#534) --- crates/effects/src/catalog.rs | 3 +++ crates/effects/src/distort3.rs | 36 +++++++++++++++++++++++++++++++--- crates/gpu/src/fx_stylize.rs | 3 +++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 009220c84..82678eb86 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -86,6 +86,7 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ ("ec.blur.cameralens", &["irisProperties/diffractionFringe", "blurMap/blurMapLayer"]), ("ec.noise.removegrain", &["temporalFiltering/enabled"]), ("ec.distort.liquify", &["viewFreezeAreaMask", "viewMesh"]), + ("ec.distort.ccpowerpin", &["unstretch"]), ("ec.matte.refinesoft", &["reduceChatter", "useMotionBlur", "invert"]), ("ec.matte.refinehard", &["reduceChatter", "useMotionBlur", "invert"]), ]; @@ -201,6 +202,8 @@ mod tests { assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))])); assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))])); assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))])); + assert!(ok("ec.distort.ccpowerpin", &[])); + assert!(!ok("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))])); assert!(ok("ec.blur.gaussian", &[])); } diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index cbba856c5..27da970f3 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -182,8 +182,9 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh); let (x1, y1) = (lw + ex[2] / 100.0 * lw, lh + ex[3] / 100.0 * lh); let corners_layer = [ctx.params.v2("topLeft"), ctx.params.v2("topRight"), ctx.params.v2("bottomRight"), ctx.params.v2("bottomLeft")]; - // Grow the buffer to hold the pinned quad. - if !ctx.adjustment { + let unstretch = ctx.params.b("unstretch"); + // Grow the buffer to hold the pinned quad (Unstretch writes inside the layer rectangle, so it needs no room). + if !ctx.adjustment && !unstretch { let mut need = 0.0f64; for c in &corners_layer { let (px, py) = b.to_px(*c); @@ -198,7 +199,8 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { vec2(p.0, p.1) }); let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0); - let Some(inv) = Mat3::square_to_quad(q).inverse() else { return b }; + let fwd = Mat3::square_to_quad(q); + let Some(inv) = fwd.inverse() else { return b }; let src = b.img.clone(); let (sc, off) = (b.scale, b.offset); let bil = move |u: f64, v: f64| -> (f64, f64) { @@ -208,6 +210,17 @@ fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { }; b.img = crate::util::gen_image(src.width, src.height, |x, y| { let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + if unstretch { + // Reverse pinning: the layer rectangle is the target, and the pinned quad is the region that fills it. + let u = ((px - off[0]) / sc - x0) / (x1 - x0); + let v = ((py - off[1]) / sc - y0) / (y1 - y0); + if !(-1e-9..=1.0 + 1e-9).contains(&u) || !(-1e-9..=1.0 + 1e-9).contains(&v) { + return [0.0; 4]; + } + let m = fwd.apply(vec2(u, v)); + let (bx, by) = bil(u, v); + return src.sample_bilinear(bx + (m.x - bx) * persp, by + (m.y - by) * persp); + } let m = inv.apply(vec2(px, py)); let (mut u, mut v) = (m.x, m.y); if persp < 1.0 { @@ -1127,6 +1140,23 @@ mod tests { assert!((px[0] - 0.5).abs() < 0.1, "{px:?}"); } + #[test] + fn power_pin_unstretch_expands_pinned_region() { + let im = crate::util::gen_image(24, 16, |_, _| [1.0, 0.0, 0.0, 1.0]); + let inset = [("topLeft", pt(6.0, 4.0)), ("topRight", pt(18.0, 4.0)), ("bottomRight", pt(18.0, 12.0)), ("bottomLeft", pt(6.0, 12.0))]; + let opaque = |o: &Buf| o.img.data.iter().filter(|p| p[3] > 0.99).count(); + let off = run("ec.distort.ccpowerpin", &inset, im.clone(), &[]); + assert_eq!(opaque(&off), 96); + let mut set = inset.to_vec(); + set.push(("unstretch", Value::Bool(true))); + let on = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); + assert_eq!(opaque(&on), 24 * 16); + assert!(on.img.get(0, 0)[3] > 0.99); + // Default corners are the identity either way. + let id = run("ec.distort.ccpowerpin", &[("unstretch", Value::Bool(true))], im.clone(), &[]); + assert!(maxd(&id.img, &im) < 1e-4); + } + #[test] fn split_opens_a_gap() { let im = img(40, 30); diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index c39b8d169..8aee71ed2 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -486,6 +486,9 @@ fn ripple_pulse(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { } fn power_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + if ctx.params.b("unstretch") { + return None; // Unstretch (reverse pinning) renders on the CPU (catalog CPU_ONLY_CONTROLS). + } let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0); let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); let ex = [ctx.params.f("expandTop"), ctx.params.f("expandLeft"), ctx.params.f("expandRight"), ctx.params.f("expandBottom")]; From cbe740dce8974476a05cba4d2baabeabceb2d0c9 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:10:57 +0200 Subject: [PATCH 09/23] CC Simple Wire Removal: Frame Offset takes the strip from the previous or next frame (#533) --- crates/effects/src/keying2.rs | 65 ++++++++++++++++++++++++++++++- crates/gpu/src/fx_pixel2.rs | 4 ++ crates/gpu/src/tests_fx_pixel2.rs | 1 + 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/effects/src/keying2.rs b/crates/effects/src/keying2.rs index 07d14c6ec..f594bfbb3 100644 --- a/crates/effects/src/keying2.rs +++ b/crates/effects/src/keying2.rs @@ -340,6 +340,24 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf { let (nx, ny) = (-ty, tx); let src = b.img.clone(); let soft_w = half * slope as f64; + // Frame Offset: the same pixels from the previous or next frame, resampled into this grid. + let offset = ctx.params.f("frameOffset").round(); + let neighbour = if style == 1 && offset != 0.0 { + ctx.env.host.and_then(|h| h.self_at(ctx.time + offset / ctx.fps(), ctx.env.effect_index)).map(|nb| { + if nb.img.width == b.img.width && nb.img.height == b.img.height && nb.offset == b.offset && nb.scale == b.scale { + nb.img + } else { + let k = nb.scale / b.scale.max(1e-9); + crate::util::gen_image(b.img.width, b.img.height, |x, y| { + let lx = (x as f64 + 0.5 - b.offset[0]) * k + nb.offset[0]; + let ly = (y as f64 + 0.5 - b.offset[1]) * k + nb.offset[1]; + nb.img.sample_bilinear(lx, ly) + }) + } + }) + } else { + None + }; b.img.rows_mut().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay); @@ -357,7 +375,9 @@ fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf { let t = ((d + edge) / (2.0 * edge)) as f32; lerp4(pos(-edge), pos(edge), t.clamp(0.0, 1.0)) } - // Displace: shift the image across the wire from the nearer side. + 1 if neighbour.is_some() => neighbour.as_ref().map_or(*px, |n| n.get(x as i64, y as i64)), + // Displace: shift the image across the wire from the nearer side (also Frame Offset + // when no other frame is available). 2 | 1 => { let s = if d >= 0.0 { d + 2.0 * (half - ad) + 1.0 } else { d - 2.0 * (half - ad) - 1.0 }; let sh = pos(s); @@ -560,6 +580,49 @@ mod tests { } } + #[test] + fn wire_removal_frame_offset_uses_neighbour_frame() { + use crate::{EffectHost, LayerPixels}; + struct Frames; + impl EffectHost for Frames { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + // Frame n (at 10 fps) is a flat grey of n / 10. + let v = (t * 10.0).round() as f32 / 10.0; + Some(Buf { img: Image::filled(20, 20, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 }) + } + } + let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]); + for y in 0..20 { + img.set(10, y, [0.0, 0.0, 0.0, 1.0]); + } + let host = Frames; + let run = |style: u32, off: f64| { + let env = EffectEnv { host: Some(&host), frame_rate: 10.0, ..Default::default() }; + let vals = [ + ("pointA", Value::Vec2([10.5, 0.0])), + ("pointB", Value::Vec2([10.5, 20.0])), + ("removalStyle", Value::Enum(style)), + ("thickness", num(3.0)), + ("frameOffset", num(off)), + ]; + run_fx("ec.key.ccsimplewireremoval", &vals, img.clone(), 0.5, env).img + }; + // At 0.5 s (frame 5) offset -1 shows frame 4 and +1 shows frame 6 inside the strip. + let (prev, next) = (run(1, -1.0), run(1, 1.0)); + assert!((prev.get(10, 10)[0] - 0.4).abs() < 1e-4, "{:?}", prev.get(10, 10)); + assert!((next.get(10, 10)[0] - 0.6).abs() < 1e-4, "{:?}", next.get(10, 10)); + // Outside the strip the current frame stays. + assert_eq!(prev.get(2, 10), img.get(2, 10)); + // Displace ignores Frame Offset. + assert_eq!(run(2, -1.0).data, run(2, 1.0).data); + } + #[test] fn inner_outer_key_more_masks_and_cleanup_strokes() { let sq = |x0: f64, x1: f64| MaskShape { name: String::new(), points: vec![[x0, 2.0], [x1, 2.0], [x1, 8.0], [x0, 8.0]], closed: true, inverted: false }; diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index 1b1b42bef..9de26f013 100644 --- a/crates/gpu/src/fx_pixel2.rs +++ b/crates/gpu/src/fx_pixel2.rs @@ -476,6 +476,10 @@ fn wire_removal(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { if half <= 0.0 { return Some(b); } + // Frame Offset reads another frame from the effect host: that renders on the CPU. + if ctx.params.e("removalStyle") == 1 && ctx.params.f("frameOffset").round() != 0.0 && ctx.env.host.is_some() { + return None; + } let slope = (ctx.params.f("slope") / 100.0).clamp(0.0, 1.0) as f32; let mirror = (ctx.params.f("mirrorBlend") / 100.0).clamp(0.0, 1.0) as f32; let a = b.to_px(ctx.params.v2("pointA")); diff --git a/crates/gpu/src/tests_fx_pixel2.rs b/crates/gpu/src/tests_fx_pixel2.rs index abd0c6556..899955b85 100644 --- a/crates/gpu/src/tests_fx_pixel2.rs +++ b/crates/gpu/src/tests_fx_pixel2.rs @@ -243,6 +243,7 @@ fn keys_and_3d() { effect_case("ec.key.ccsimplewireremoval", &[]); effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(0)), ("thickness", n(8.0)), ("slope", n(50.0))]); effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(1)), ("mirrorBlend", n(60.0)), ("pointB", pt(60.0, 10.0))]); + effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(1)), ("frameOffset", n(-2.0)), ("thickness", n(6.0))]); effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(3)), ("mirrorBlend", n(30.0)), ("slope", n(20.0))]); effect_case("ec.obsolete.basic3d", &[("swivel", n(25.0))]); effect_case("ec.obsolete.basic3d", &[("tilt", n(-30.0)), ("swivel", n(10.0)), ("specularHighlight", on())]); From 60a85e97d41f1d665737fc65969faae6522ce9c4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:11:11 +0200 Subject: [PATCH 10/23] Convert Expression to Keyframes: sort the keys by layer time on a time-reversed layer (#531) --- crates/engine/src/commands/prop.rs | 2 ++ crates/engine/src/tests_timeline.rs | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index cf4593938..cb8c3ad27 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -981,6 +981,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result { keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr))); t += fd; } + // A time-reversed layer maps ascending comp times to descending layer times; keys must stay ordered by layer time. + keys.sort_by_key(|k| k.time); let n = keys.len(); with_prop(s, "Convert Expression to Keyframes", None, cid, lid, uid, move |pr, _| { pr.keys = keys; diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index de746f668..1babd8359 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -242,6 +242,25 @@ fn graph_editor_key_and_handle_edits() { assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear); } +#[test] +fn convert_expression_to_keyframes_on_a_reversed_layer_keeps_values() { + let (mut s, l) = setup(); + // These tests run without an expression host, so the conversion bakes the keyed ramp under the expression. + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (3.0, json!(30))]); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/rotation", "expression": "value"})).unwrap(); + s.execute("layer.timeReverse", json!({"layers": [l]})).unwrap(); + let at = |t: f64| json!({"layer": l, "path": "transform/rotation", "time": t}); + let sample = |s: &mut Session| [0.5, 1.0, 2.0].map(|t| s.execute("prop.get", at(t)).unwrap()["value"].as_f64().unwrap()); + let before = sample(&mut s); + assert!(before[0] > before[1] && before[1] > before[2], "{before:?}"); + s.execute("prop.convertExpressionToKeyframes", json!({"layer": l, "path": "transform/rotation"})).unwrap(); + let keys = prop(&s, l, "transform/rotation").keys; + assert!(keys.len() > 1); + assert!(keys.windows(2).all(|w| w[0].time < w[1].time)); + let after = sample(&mut s); + assert!(before.iter().zip(after).all(|(b, a)| (b - a).abs() < 1e-6), "{before:?} -> {after:?}"); +} + #[test] fn time_reverse_keyframes() { let (mut s, l) = setup(); From 4ea35a63a26faeddeacf12708cfd514b53b4120d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:22:56 +0200 Subject: [PATCH 11/23] Fix recording a shortcut: the field no longer types the key too, and Cmd++ parses (#467) --- crates/ui-egui/src/menus.rs | 11 +++++- crates/ui-egui/src/panels/shortcut_editor.rs | 37 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7851779ff..9283dce27 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1391,7 +1391,16 @@ pub fn shortcut_text(s: &str) -> String { pub fn parse_shortcut(s: &str) -> Option<(egui::Modifiers, egui::Key)> { let mut m = egui::Modifiers::NONE; let mut key = None; - let parts: Vec<&str> = if s == "+" { vec!["+"] } else { s.split('+').collect() }; + // The plus key itself is written `+` or `Cmd++`. + let (head, plus) = match s.strip_suffix("++") { + Some(h) => (h, true), + None if s == "+" => ("", true), + None => (s, false), + }; + let mut parts: Vec<&str> = if head.is_empty() { vec![] } else { head.split('+').collect() }; + if plus { + parts.push("+"); + } for p in parts { match p { "Cmd" => { diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index c0893c5b1..021dffe75 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -204,6 +204,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { _ => None, }) }); + // The shortcut field must not also type the key it just recorded (Alt+R came out as `rAlt+R`). + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Text(_) | egui::Event::Key { .. }))); if let Some((k, m)) = got { st.recording = false; if k != egui::Key::Escape @@ -586,6 +588,41 @@ mod tests { } assert_eq!(split("Cmd+Shift+K"), ([false, true, false, true], "K".into())); assert_eq!(split("Cmd++"), ([false, true, false, false], "+".into())); + // The plus key, with and without modifiers, parses back (typed by hand or recorded). + for txt in ["+", "Cmd++", "Alt++"] { + assert_eq!(crate::menus::parse_shortcut(txt).map(|(_, k)| k), Some(egui::Key::Plus), "{txt}"); + } + assert_eq!(crate::menus::parse_shortcut("Alt+R").map(|(m, k)| (m.alt, k)), Some((true, egui::Key::R))); assert_eq!(split("F9"), ([false; 4], "F9".into())); } + + fn frame(app: &mut EffectcraftApp, ctx: &egui::Context, events: Vec) { + app.auto.begin_frame(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::panels::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + #[test] + fn recording_a_shortcut_does_not_also_type_the_key_into_the_field() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let ctx = egui::Context::default(); + crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app)); + open(&mut app); + app.dialog_state.shortcuts.selected = app.session.shortcuts().bindables.first().map(|b| b.key.clone()); + for _ in 0..3 { + frame(&mut app, &ctx, vec![]); + } + let [x, y, w, h] = app.auto.find("shortcuts.record").expect("shortcut field").rect; + let at = pos2(x + w / 2.0, y + h / 2.0); + let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(true)]); + frame(&mut app, &ctx, vec![click(false)]); + frame(&mut app, &ctx, vec![]); + assert!(app.dialog_state.shortcuts.recording); + // Alt+R arrives as the key and as the typed text `r`. + let key = egui::Event::Key { key: egui::Key::R, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::ALT }; + frame(&mut app, &ctx, vec![key, egui::Event::Text("r".into())]); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.dialog_state.shortcuts.pending, "Alt+R"); + } } From 0dc4ba381219cef1bb8462b9f5c79418b1e8c2c8 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:27:23 +0200 Subject: [PATCH 12/23] Reopen panels from the Window menu when the dock is empty (#529) --- crates/ui-egui/src/dock.rs | 41 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 65bca8803..8785aeb29 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -501,8 +501,27 @@ impl DockNode { }, } } - if !add(self, p, near) && !add(self, p, PanelKind::EffectsPresets) { - add(self, p, PanelKind::Project); + fn add_first(n: &mut DockNode, p: PanelKind) -> bool { + match n { + DockNode::Split { a, b, .. } => add_first(a, p) || add_first(b, p), + DockNode::Tabs { panels, active } if !panels.is_empty() => { + panels.push(p); + *active = panels.len() - 1; + true + } + DockNode::Stack { entries } if !entries.is_empty() => { + entries.push(StackEntry { panel: p, open: true, height: None }); + true + } + _ => false, + } + } + if self.panel_count() == 0 { + // Every panel was closed: there is no anchor, so the panel becomes the whole dock. + *self = tabs(&[p], 0); + } else if !add(self, p, near) && !add(self, p, PanelKind::EffectsPresets) && !add(self, p, PanelKind::Project) { + // None of the anchors is docked (e.g. Minimal with Timeline closed): use the first group. + add_first(self, p); } } } @@ -1181,6 +1200,24 @@ pub fn placeholder(ui: &mut egui::Ui, rect: Rect, t: &Tokens, text: &str) { mod tests { use super::*; + #[test] + fn open_near_in_an_empty_dock_makes_the_panel_the_dock() { + for p in [PanelKind::Composition, PanelKind::Timeline, PanelKind::Project] { + let mut d = tabs(&[], 0); + d.open_near(p, PanelKind::EffectsPresets); + assert!(d.is_visible(p)); + assert_eq!(d.panel_count(), 1); + } + } + + #[test] + fn open_near_without_any_anchor_uses_the_first_group() { + let mut d = tabs(&[PanelKind::Composition], 0); + d.open_near(PanelKind::Timeline, PanelKind::EffectsPresets); + assert!(d.is_visible(PanelKind::Timeline)); + assert_eq!(d.panel_count(), 2); + } + #[test] fn stacked_panels_toggle_open_close() { use PanelKind::*; From 576d386d55a1b446ad42a9881eb012c82f7a5498 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:26:42 +0200 Subject: [PATCH 13/23] Render queue: unsaved project outputs go to ~/Movies or home, not the working directory (#446) --- crates/engine/src/render_queue.rs | 14 +++++++++++++- crates/engine/src/tests_m145.rs | 10 ++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 1bf95577b..666a56cca 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -157,6 +157,14 @@ struct Work { nested_switches: bool, } +/// Where a relative render output goes when there is no default folder and the project is unsaved: +/// `~/Movies` when it exists, else the home folder itself. +pub(crate) fn default_output_base(home: Option) -> Option { + let home = home.filter(|h| !h.as_os_str().is_empty())?; + let movies = home.join("Movies"); + Some(if movies.is_dir() { movies } else { home }) +} + /// Bits per channel an output format writes for a project bit depth (named in the file when /// Settings ▸ Export ▸ Append Bit Depth to File Name is on). pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) -> u32 { @@ -324,7 +332,7 @@ impl Session { /// The output path of a queue item: template tokens expanded, ending in the format's /// extension exactly once ([`OutputFormat::with_extension`]), relative paths resolved against - /// the project's folder (or the working directory). `None` when the comp is gone or the + /// the project's folder (an unsaved project's against ~/Movies or home). `None` when the comp is gone or the /// output is empty ("Needs Output"). pub fn resolve_output(&self, item: &RenderQueueItem) -> Option { let comp = self.project.comp(item.comp)?; @@ -351,6 +359,10 @@ impl Session { let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder)); let base = base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty())); + // Unsaved project: ~/Movies, else the home folder (as FilmCraft's exports do). The working directory + // is `/` for an app started from the macOS Finder, where nothing can be written (#446). + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(std::path::PathBuf::from); + let base = base.or_else(|| default_output_base(home)); // (the web has no working directory: relative outputs land in its virtual root) let base = base.or_else(|| std::env::current_dir().ok()).or_else(|| cfg!(target_arch = "wasm32").then(|| "/".into()))?; Some(base.join(path).to_string_lossy().to_string()) diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs index b55442eb8..972bcdc33 100644 --- a/crates/engine/src/tests_m145.rs +++ b/crates/engine/src/tests_m145.rs @@ -682,3 +682,13 @@ fn dynamic_menus_and_window_viewer_labels() { assert!(labels.iter().any(|x| x == l), "{l}"); } } + +#[test] +fn unsaved_project_renders_to_movies_or_home_not_the_working_directory() { + // A Finder-launched app has `/` as its working directory, where renders fail (#446). + let home = tmp("home446"); + assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.clone())); + std::fs::create_dir_all(home.join("Movies")).unwrap(); + assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.join("Movies"))); + assert_eq!(crate::render_queue::default_output_base(None), None); +} From 068db1f36bb5d47f87fd206e0e1270277cc1c216 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:28:22 +0200 Subject: [PATCH 14/23] Let pan and zoom work while 3D Camera Tracker points are shown (#505) --- .../ui-egui/src/panels/camera_tracker_ui.rs | 16 +++++- crates/ui-egui/src/panels/viewer.rs | 4 +- crates/ui-egui/tests/ui_camera_tracker.rs | 54 +++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index 18e248135..71cea1554 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -215,8 +215,22 @@ pub fn viewer_hook( return; } // Interaction over the image. - let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), Sense::click_and_drag()); + // While Space (Hand tool) is held the overlay only hovers, so the viewer underneath gets the pan drag. + let sense = if super::space_hand(&ctx) { Sense::hover() } else { Sense::click_and_drag() }; + let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), sense); app.auto.add("viewer.cameraTracker", map.area, "3D Camera Tracker points"); + // The overlay is on top of the viewer, so a middle-button drag lands here: pan the viewer like the Hand tool. + if resp.dragged_by(egui::PointerButton::Middle) { + let pid = egui::Id::new("viewer-camera-tracker-pan"); + if resp.drag_started() { + let start_pan = app.ui.viewer.pan; + ui.data_mut(|d| d.insert_temp(pid, start_pan)); + } + if let (Some(start_pan), Some(d)) = (ui.data(|m| m.get_temp::<[f32; 2]>(pid)), resp.total_drag_delta()) { + app.ui.viewer.pan = [start_pan[0] + d.x, start_pan[1] + d.y]; + } + return; + } for (id, sp, r) in hits.iter().take(400) { app.auto.add(&format!("viewer.cameraTracker.point.{id}"), Rect::from_center_size(*sp, vec2(r * 2.0 + 4.0, r * 2.0 + 4.0)), "track point"); } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9adf007f8..f76c47ac5 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1044,7 +1044,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } let gid = egui::Id::new("viewer-gesture"); - if let Some(hp) = resp.hover_pos() { + // An overlay on top of the viewer (e.g. the camera tracker points) takes the hover, but the pointer is still over the viewer: keep wheel zoom working. + let hover_pos = resp.hover_pos().or_else(|| if resp.contains_pointer() { ui.input(|i| i.pointer.hover_pos()) } else { None }); + if let Some(hp) = hover_pos { app.pointer_comp = Some({ let c = map.to_comp(hp); [c[0] as f32, c[1] as f32] diff --git a/crates/ui-egui/tests/ui_camera_tracker.rs b/crates/ui-egui/tests/ui_camera_tracker.rs index 9e0bede60..a90c3fa45 100644 --- a/crates/ui-egui/tests/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/ui_camera_tracker.rs @@ -145,6 +145,60 @@ fn viewer_points_selection_and_effect_controls() { assert!(h.state().session.is_enabled("track.camera")); } +/// Drag with `button` from `from` to `to` in steps, then release. +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: egui::Pos2, to: egui::Pos2) { + h.input_mut().events.push(egui::Event::PointerMoved(from)); + h.input_mut().events.push(egui::Event::PointerButton { pos: from, button, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(egui::Event::PointerButton { pos: to, button, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +/// #505: with the track points shown, the wheel still zooms the viewer, and a middle-button drag +/// and a Spacebar (Hand tool) drag over the points still pan it. +#[test] +fn viewer_zooms_and_pans_over_the_track_points() { + let (a, ..) = app(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| a); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(4); + // Wheel over a point: zooms. + let at = rect(&h, "viewer.cameraTracker.point.7").center(); + let zoom = h.state().ui.viewer.zoom; + h.input_mut().events.push(egui::Event::PointerMoved(at)); + h.input_mut().events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, 120.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }); + h.run_steps(20); + assert_ne!(h.state().ui.viewer.zoom, zoom, "the wheel zooms over the track points"); + // Middle-button drag from a point: pans, and selects nothing. + let at = rect(&h, "viewer.cameraTracker.point.7").center(); + let before = h.state().ui.viewer.pan; + drag_with(&mut h, egui::PointerButton::Middle, at, at + egui::vec2(120.0, 60.0)); + let after = h.state().ui.viewer.pan; + assert!((after[0] - before[0] - 120.0).abs() < 1.0 && (after[1] - before[1] - 60.0).abs() < 1.0, "{before:?} → {after:?}"); + assert!(h.state().session.state.camera_points.is_empty(), "a pan selects no points"); + // Spacebar held: a primary drag from a point pans instead of drawing a marquee. + let at = rect(&h, "viewer.cameraTracker.point.7").center(); + let space = |h: &mut Harness<'_, EffectcraftApp>, pressed: bool| { + h.input_mut().events.push(egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Default::default() }); + h.step(); + }; + space(&mut h, true); + drag_with(&mut h, egui::PointerButton::Primary, at, at + egui::vec2(80.0, 40.0)); + space(&mut h, false); + let end = h.state().ui.viewer.pan; + assert!((end[0] - after[0] - 80.0).abs() < 1.0 && (end[1] - after[1] - 40.0).abs() < 1.0, "{after:?} → {end:?}"); + assert!(h.state().session.state.camera_points.is_empty(), "a Spacebar pan selects no points"); +} + /// Step the UI until background frame renders have landed. fn settle(h: &mut Harness<'_, EffectcraftApp>) { for _ in 0..600 { From 1922e621c22be3125fdb8d03282c52645f8152b6 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:58:35 +0700 Subject: [PATCH 15/23] fix(ui): drop all selected Project items into compositions (#351) Preserve Project multi-selection when dropping onto the Timeline or Composition viewer. Keep stack order and drop position, skip non-layer project items, and add pointer-drag regression coverage. --- crates/ui-egui/src/panels/mod.rs | 24 +++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 9 ++++-- crates/ui-egui/src/panels/viewer_drop.rs | 26 +++++++++------- crates/ui-egui/tests/ui_drag_drop.rs | 38 ++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index ab5a1c8f7..76691dce1 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -78,6 +78,30 @@ pub enum DragPayload { Files(Vec), } +/// Layers carried by a Project item drag. Dragging a selected item uses the +/// complete selection, while dragging an unselected item uses only that item. +/// Folders and data files are not composition layers. +pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec { + use effectcraft_engine::project::{FootageKind, ItemId, ItemKind}; + + let selected = &app.session.state.project_selection; + let candidates: Vec = if selected.iter().any(|id| id.0 == dragged) { + selected.iter().map(|id| id.0).collect() + } else { + vec![dragged] + }; + candidates + .into_iter() + .filter(|id| { + app.session.project.item(ItemId(*id)).is_some_and(|item| match &item.kind { + ItemKind::Comp(_) | ItemKind::Solid(_) => true, + ItemKind::Footage(f) => f.kind != FootageKind::Data, + ItemKind::Folder => false, + }) + }) + .collect() +} + /// The current time formatted per project settings (timecode with `;` for drop-frame, frames /// or Feet + Frames). pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7df3439be..878a577fb 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2959,9 +2959,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { rp.galley(tag.min, g, Color32::WHITE); } if released { - let (index, time) = (json!(index), json!(start.map(Tick::seconds))); + let time = json!(start.map(Tick::seconds)); match payload.as_ref() { - crate::panels::DragPayload::Item(id) => actions.push(("layer.addItem".into(), json!({"item": id, "index": index, "time": time}))), + crate::panels::DragPayload::Item(id) => { + // Insert each selected item at the drop point, in selection order. + for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() { + actions.push(("layer.addItem".into(), json!({"item": item, "index": index + offset, "time": time.clone()}))); + } + } crate::panels::DragPayload::Files(paths) => { actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time}))) } diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 31d426b36..dec8d3802 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -23,32 +23,38 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(map.area)) else { return }; let stroke = Stroke::new(2.0, app.tokens.accent); let at = map.to_comp(ptr); - let action: Option<(&str, Value)> = match payload.as_ref() { + let actions: Vec<(&str, Value)> = match payload.as_ref() { DragPayload::Effect(effect) => layer_at(ectx, at).map(|l| { if let Some((_, q, _)) = layer_quad(ectx, l) { painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); } ("effect.apply", json!({"effect": effect, "layers": [l.id.0]})) - }), + }).into_iter().collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { painter.rect_stroke(Rect::from_center_size(ptr, vec2(w, h) * map.zoom), 0.0, Stroke::new(1.0, app.tokens.accent), StrokeKind::Middle); } - Some(("layer.addItem", json!({"item": item, "position": at}))) + // New layers go above the current selection, so insert in reverse + // to preserve the order of the dragged Project items. + crate::panels::project_drop_items(app, *item) + .into_iter() + .rev() + .map(|item| ("layer.addItem", json!({"item": item, "position": at}))) + .collect() } - DragPayload::Files(paths) => Some(("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))), - DragPayload::Property { .. } => None, + DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))], + DragPayload::Property { .. } => vec![], }; - if action.as_ref().is_some_and(|(id, _)| *id != "effect.apply") { + if actions.first().is_some_and(|(id, _)| *id != "effect.apply") { painter.rect_stroke(map.area, 0.0, stroke, StrokeKind::Inside); } if ctx.input(|i| i.pointer.any_released()) { egui::DragAndDrop::clear_payload(&ctx); - if let Some((id, params)) = action - && let Err(e) = crate::menus::invoke(app, &ctx, id, params) - { - app.ui.status = e; + for (id, params) in actions { + if let Err(e) = crate::menus::invoke(app, &ctx, id, params) { + app.ui.status = e; + } } } } diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 9738fef28..56f9ffd72 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -123,6 +123,44 @@ fn project_items_dropped_on_the_viewer_land_under_the_pointer() { assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); } +/// #351: dragging one of several selected Project items inserts all the items, +/// in selection order, instead of only the item the drag started on. +#[test] +fn dragging_selected_project_items_adds_every_item_to_the_timeline() { + let (mut h, clip) = harness(); + let main = h.state().session.active_comp_id().unwrap().0; + let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); + h.state_mut().session.state.project_selection = + vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; + h.run_steps(3); + let from = rect(&h, &format!("project.item.{clip}.name")).center(); + let top = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Top"))); + drag(&mut h, from, pos2(top.center().x, top.max.y - 2.0), Modifiers::NONE); + assert_eq!(stack(&h), ["Top", "Clip", "Other", "Middle", "Bottom"]); + assert_eq!(in_point(&h, 1), in_point(&h, 2), "both layers use the drop time"); +} + +#[test] +fn dragging_selected_project_items_adds_every_item_to_the_viewer() { + let (mut h, clip) = harness(); + let main = h.state().session.active_comp_id().unwrap().0; + let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); + h.state_mut().session.state.project_selection = + vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; + h.run_steps(3); + let from = rect(&h, &format!("project.item.{clip}.name")).center(); + let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(stack(&h), ["Clip", "Other", "Top", "Middle", "Bottom"]); + for layer in &h.state().session.active_comp().unwrap().layers[..2] { + let Some(KV::Vec3(p)) = layer.props.prop("transform/position").map(|p| p.value.clone()) else { panic!("no position") }; + let tol = 1.0 / last_fit(&h.ctx) as f64 + 1e-6; + assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}"); + } +} + /// #88: an effect dragged from Effects & Presets onto the viewer goes on the layer under the /// pointer, not the selected one. #[test] From c8657bf2146ca8b449f3884624db51fdc9259a23 Mon Sep 17 00:00:00 2001 From: FrederoxDev Date: Sat, 10 Oct 2026 19:26:20 +0100 Subject: [PATCH 16/23] UI: one After Effects-style colour picker with a hex field for every colour input Every colour input opens one picker laid out like After Effects' Color Picker: a square and slider for the channel chosen with H / S / B / R / G / B, the new colour over the original, HSB and RGB fields, a hex field (#RRGGBB, RRGGBB, #RGB) and OK / Cancel. Changes apply live; Cancel, Escape and the original swatch restore the starting colour. The 3D light dialog, Essential Graphics and Layer Styles passed sRGB values to egui's color_edit_button_rgb(a), which reads them as linear, so picked colours were stored darker (#808080 became #373737). They and the other dialogs now use the shared sRGB colour button. The picker and colour helpers live in color_picker.rs; widgets re-exports them. Tests: ui_color_hex. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/color_picker.rs | 411 ++++++++++++++++++ crates/ui-egui/src/header.rs | 32 +- crates/ui-egui/src/i18n/ui.rs | 2 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/dialogs_3d.rs | 2 +- crates/ui-egui/src/panels/effect_controls.rs | 2 +- crates/ui-egui/src/panels/essential.rs | 7 +- crates/ui-egui/src/panels/fx_editors.rs | 2 +- .../ui-egui/src/panels/layer_styles_dialog.rs | 6 +- crates/ui-egui/src/panels/paint_panels.rs | 4 +- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/settings.rs | 16 +- crates/ui-egui/src/panels/text_panels.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 2 +- crates/ui-egui/src/panels/viewer.rs | 9 +- crates/ui-egui/src/widgets.rs | 13 +- crates/ui-egui/tests/ui_color_hex.rs | 233 ++++++++++ 17 files changed, 678 insertions(+), 68 deletions(-) create mode 100644 crates/ui-egui/src/color_picker.rs create mode 100644 crates/ui-egui/tests/ui_color_hex.rs diff --git a/crates/ui-egui/src/color_picker.rs b/crates/ui-egui/src/color_picker.rs new file mode 100644 index 000000000..cd24dbc0e --- /dev/null +++ b/crates/ui-egui/src/color_picker.rs @@ -0,0 +1,411 @@ +//! The colour picker every colour input opens, laid out like After Effects' Color Picker: a +//! square and a slider for the channel chosen with the H / S / B / R / G / B buttons, the new +//! colour over the original, the HSB and RGB values, the hex code, and OK / Cancel. +//! +//! Colours are sRGB components (0–1), as the project stores them. Changes apply as they are made +//! (After Effects' Preview); Cancel or Escape goes back to the colour the picker opened with. + +use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; + +use crate::i18n::tr; +use crate::widgets::{open_popup, popup_is_open, pressed_outside, select_all}; + +/// A colour button for egui-laid-out rows and dialogs: a swatch that opens [`color_popup`]. Don't +/// use egui's `color_edit_button_rgb(a)` for project colours: they read linear values, so the +/// swatch shows lighter and a picked colour is stored darker. The value is only rewritten when the +/// user picks a colour. +pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response { + let (rect, mut resp) = ui.allocate_exact_size(ui.spacing().interact_size, Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ColorButton, ui.is_enabled(), srgb_hex(*c))); + let stroke = if resp.hovered() { ui.visuals().widgets.hovered.fg_stroke } else { ui.visuals().widgets.inactive.bg_stroke }; + ui.painter().rect_filled(rect, 2.0, srgb32(*c)); + ui.painter().rect_stroke(rect, 2.0, stroke, StrokeKind::Inside); + if resp.clicked() { + open_popup(ui, resp.id); + } + if color_popup(ui, resp.id, rect.left_bottom(), c) { + resp.mark_changed(); + } + resp +} + +/// The colour picker as a popup at `pos`, bound to `c`, opened with [`open_popup`]. Returns true +/// when the colour changed (Cancel, Escape and the original swatch change it back). +pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool { + if !popup_is_open(ui, id) { + return false; + } + let orig_id = id.with("original"); + let original = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(orig_id, || *c)); + let mut out = Outcome::default(); + let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| out = color_picker(ui, id, c, original)); + }); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + out.close = Some(false); + } + if pressed_outside(ui.ctx(), &area.response) { + out.close.get_or_insert(true); + } + if let Some(keep) = out.close { + if !keep && *c != original { + *c = original; + out.changed = true; + } + ui.data_mut(|d| { + d.insert_temp(id.with("open"), false); + d.remove::<[f32; 3]>(orig_id); + }); + } + out.changed +} + +/// What the picker did this frame. +#[derive(Default)] +struct Outcome { + changed: bool, + /// Close the picker, keeping the colour (OK) or not (Cancel). + close: Option, +} + +/// The channel the slider sets; the square sets the other two of its model. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Chan { + H, + S, + V, + R, + G, + B, +} + +impl Chan { + /// The square's (across, up) channels while `self` is on the slider (After Effects' layout). + fn square(self) -> (Chan, Chan) { + match self { + Chan::H => (Chan::S, Chan::V), + Chan::S => (Chan::H, Chan::V), + Chan::V => (Chan::H, Chan::S), + Chan::R => (Chan::B, Chan::G), + Chan::G => (Chan::B, Chan::R), + Chan::B => (Chan::R, Chan::G), + } + } + + fn index(self) -> usize { + match self { + Chan::H | Chan::R => 0, + Chan::S | Chan::G => 1, + Chan::V | Chan::B => 2, + } + } + + fn is_hsv(self) -> bool { + matches!(self, Chan::H | Chan::S | Chan::V) + } +} + +/// The colour as both HSB and RGB: HSB keeps the hue (and saturation) of greys and black, where +/// RGB has none, so moving off black returns to the hue that was chosen. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Pick { + hsv: [f32; 3], + rgb: [f32; 3], +} + +impl Pick { + fn get(&self, ch: Chan) -> f32 { + let v = if ch.is_hsv() { self.hsv } else { self.rgb }; + v.get(ch.index()).copied().unwrap_or(0.0) + } + + fn set(mut self, ch: Chan, v: f32) -> Pick { + let v = v.clamp(0.0, 1.0); + if ch.is_hsv() { + if let Some(x) = self.hsv.get_mut(ch.index()) { + *x = v; + } + self.rgb = hsv_to_rgb(self.hsv); + } else { + if let Some(x) = self.rgb.get_mut(ch.index()) { + *x = v; + } + self.hsv = keep_hue(rgb_to_hsv(self.rgb), self.hsv); + } + self + } + + /// The colour at (`u` across, `v` up) on the square, or `t` up the slider. + fn at(&self, ch: Chan, u: f32, v: f32) -> Pick { + let (x, y) = ch.square(); + self.set(x, u).set(y, v) + } +} + +const SQUARE: f32 = 200.0; +const SLIDER_W: f32 = 16.0; + +fn color_picker(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3], original: [f32; 3]) -> Outcome { + let state_id = id.with("pick"); + let mut pick = match ui.data(|d| d.get_temp::<([f32; 3], [f32; 3])>(state_id)) { + Some((hsv, rgb)) if rgb == *c => Pick { hsv, rgb }, + Some((hsv, _)) => Pick { hsv: keep_hue(rgb_to_hsv(*c), hsv), rgb: *c }, + None => Pick { hsv: rgb_to_hsv(*c), rgb: *c }, + }; + // The slider's channel is remembered between pickers, as After Effects does. + let chan_id = egui::Id::new("color-picker-channel"); + let mut chan = ui.data(|d| d.get_temp::(chan_id)).and_then(|i| CHANS.get(i).map(|c| c.0)).unwrap_or(Chan::H); + let before = pick; + let mut out = Outcome::default(); + + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + // The square and slider, with OK and Cancel under the square's left edge. + ui.vertical(|ui| { + ui.horizontal_top(|ui| { + square(ui, chan, &mut pick); + slider(ui, chan, &mut pick); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + if ui.add_sized(vec2(72.0, 20.0), egui::Button::new(tr("OK"))).clicked() { + out.close = Some(true); + } + if ui.add_sized(vec2(72.0, 20.0), egui::Button::new(tr("Cancel"))).clicked() { + out.close = Some(false); + } + }); + }); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 4.0; + if swatches(ui, pick.rgb, original) { + pick = Pick { hsv: keep_hue(rgb_to_hsv(original), pick.hsv), rgb: original }; + } + ui.add_space(4.0); + let mut field = |ui: &mut Ui, ch: Chan, name: &str, scale: f32, suffix: &str| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let radio = ui.radio(chan == ch, format!("{name}:")); + // Line the fields up whatever each label's width. + ui.add_space((40.0 - radio.rect.width()).max(0.0)); + if radio.clicked() { + chan = ch; + } + // Rounded as the hex code rounds, and 360° shows as 0°. + let mut v = (pick.get(ch) * scale).round(); + if ch == Chan::H && v >= 360.0 { + v = 0.0; + } + let drag = egui::DragValue::new(&mut v).range(0.0..=scale).fixed_decimals(0).suffix(suffix).speed(scale / 255.0); + if ui.add_sized(vec2(56.0, 18.0), drag).labelled_by(radio.id).changed() { + pick = pick.set(ch, v.round() / scale); + } + }); + }; + field(ui, Chan::H, "H", 360.0, "°"); + field(ui, Chan::S, "S", 100.0, "%"); + field(ui, Chan::V, "B", 100.0, "%"); + ui.add_space(4.0); + field(ui, Chan::R, "R", 255.0, ""); + field(ui, Chan::G, "G", 255.0, ""); + field(ui, Chan::B, "B", 255.0, ""); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let mut rgb = pick.rgb; + if hex_field(ui, id.with("hex"), &mut rgb) { + pick = Pick { hsv: keep_hue(rgb_to_hsv(rgb), pick.hsv), rgb }; + } + }); + }); + }); + + if let Some(i) = CHANS.iter().position(|(c, _)| *c == chan) { + ui.data_mut(|d| d.insert_temp(chan_id, i)); + } + if pick != before && pick.rgb != *c { + *c = pick.rgb; + out.changed = true; + } + ui.data_mut(|d| d.insert_temp(state_id, (pick.hsv, pick.rgb))); + out +} + +/// The channels in the order of their buttons. +const CHANS: [(Chan, &str); 6] = [(Chan::H, "H"), (Chan::S, "S"), (Chan::V, "B"), (Chan::R, "R"), (Chan::G, "G"), (Chan::B, "B")]; + +/// Drag or click on the square to set its two channels. +fn square(ui: &mut Ui, chan: Chan, pick: &mut Pick) { + let (rect, resp) = ui.allocate_exact_size(vec2(SQUARE, SQUARE), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color field")); + let (x, y) = chan.square(); + if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) { + *pick = pick.at(chan, (p.x - rect.min.x) / rect.width(), 1.0 - (p.y - rect.min.y) / rect.height()); + } + // Hue changes unevenly across a row, so the HSB squares get a finer grid. + let n: u32 = if chan.is_hsv() { 24 } else { 8 }; + let row = n + 1; + let mut mesh = egui::Mesh::default(); + for yi in 0..=n { + for xi in 0..=n { + let (u, v) = (xi as f32 / n as f32, yi as f32 / n as f32); + mesh.colored_vertex(rect.lerp_inside(vec2(u, v)), srgb32(pick.at(chan, u, 1.0 - v).rgb)); + if xi < n && yi < n { + let k = yi * row + xi; + mesh.add_triangle(k, k + 1, k + row); + mesh.add_triangle(k + 1, k + row + 1, k + row); + } + } + } + ui.painter().add(mesh); + let at = rect.lerp_inside(vec2(pick.get(x), 1.0 - pick.get(y))); + let light = pick.rgb[0] * 0.3 + pick.rgb[1] * 0.59 + pick.rgb[2] * 0.11 > 0.6; + ui.painter().circle_stroke(at, 5.0, Stroke::new(1.5, if light { Color32::BLACK } else { Color32::WHITE })); +} + +/// Drag or click on the slider to set `chan`; arrows on both sides mark its value. +fn slider(ui: &mut Ui, chan: Chan, pick: &mut Pick) { + let (outer, resp) = ui.allocate_exact_size(vec2(SLIDER_W + 12.0, SQUARE), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color slider")); + let bar = Rect::from_center_size(outer.center(), vec2(SLIDER_W, SQUARE)); + if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) { + *pick = pick.set(chan, 1.0 - (p.y - bar.min.y) / bar.height()); + } + let n: u32 = if chan == Chan::H { 36 } else { 16 }; + let mut mesh = egui::Mesh::default(); + for i in 0..=n { + let t = i as f32 / n as f32; + // The hue slider shows pure hues; the others vary their channel in the current colour. + let col = if chan == Chan::H { hsv_to_rgb([1.0 - t, 1.0, 1.0]) } else { pick.set(chan, 1.0 - t).rgb }; + let y = bar.min.y + t * bar.height(); + mesh.colored_vertex(pos2(bar.min.x, y), srgb32(col)); + mesh.colored_vertex(pos2(bar.max.x, y), srgb32(col)); + if i < n { + let k = 2 * i; + mesh.add_triangle(k, k + 1, k + 2); + mesh.add_triangle(k + 1, k + 3, k + 2); + } + } + ui.painter().add(mesh); + let y = bar.min.y + (1.0 - pick.get(chan)) * bar.height(); + let fg = ui.visuals().strong_text_color(); + let stroke = Stroke::new(1.2, fg); + for (edge, dir) in [(outer.min.x + 1.0, 1.0), (outer.max.x - 1.0, -1.0)] { + let tip = pos2(edge + dir * 4.0, y); + ui.painter().line_segment([pos2(edge, y - 4.0), tip], stroke); + ui.painter().line_segment([pos2(edge, y + 4.0), tip], stroke); + } +} + +/// The new colour over the original; returns true when the original is clicked (to go back to it). +fn swatches(ui: &mut Ui, new: [f32; 3], original: [f32; 3]) -> bool { + let (rect, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover()); + let (top, bottom) = rect.split_top_bottom_at_fraction(0.5); + let new_resp = ui.interact(top, ui.id().with("new-swatch"), Sense::hover()); + new_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "New color")); + let old = ui.interact(bottom, ui.id().with("original-swatch"), Sense::click()).on_hover_text(tr("Original color: click to go back to it")); + old.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Original color")); + ui.painter().rect_filled(top, 0.0, srgb32(new)); + ui.painter().rect_filled(bottom, 0.0, srgb32(original)); + ui.painter().rect_stroke(rect, 0.0, ui.visuals().widgets.inactive.bg_stroke, StrokeKind::Outside); + old.clicked() +} + +/// `hsv` with `old`'s hue where `hsv` has none (a grey or black), and `old`'s saturation where it +/// has neither (black). +fn keep_hue(mut hsv: [f32; 3], old: [f32; 3]) -> [f32; 3] { + if hsv[1] == 0.0 || hsv[2] == 0.0 { + hsv[0] = old[0]; + } + if hsv[2] == 0.0 { + hsv[1] = old[1]; + } + hsv +} + +/// The hex field. While it has focus it keeps what was typed (`id` holds the text); otherwise it +/// shows the colour. Focusing it selects the code, so typing replaces it; a valid code applies as +/// it is typed. +fn hex_field(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3]) -> bool { + let mut text = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| srgb_hex(*c).trim_start_matches('#').to_string()); + let mut changed = false; + let label = ui.label("#"); + let resp = ui + .add(egui::TextEdit::singleline(&mut text).id(id.with("edit")).desired_width(62.0).char_limit(7).font(egui::TextStyle::Monospace)) + .labelled_by(label.id); + if resp.gained_focus() { + select_all(ui.ctx(), resp.id, &text); + } + if resp.changed() + && let Some(rgb) = hex_rgb(&text) + { + let v = rgb.map(|b| b as f32 / 255.0); + if v != *c { + *c = v; + changed = true; + } + } + if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(id, text.clone())); + } else { + ui.data_mut(|d| d.remove::(id)); + } + changed +} + +/// An sRGB colour (components 0–1) as an egui colour. +pub fn srgb32(c: [f32; 3]) -> Color32 { + let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) +} + +/// An sRGB colour (components 0–1) as `#RRGGBB`. +pub fn srgb_hex(c: [f32; 3]) -> String { + let col = srgb32(c); + format!("#{:02X}{:02X}{:02X}", col.r(), col.g(), col.b()) +} + +/// Parse a hex colour: `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB` (any case, surrounding space ignored). +pub fn hex_rgb(s: &str) -> Option<[u8; 3]> { + let h = s.trim().trim_start_matches('#'); + if !h.is_ascii() { + return None; + } + let digit = |i: usize| h.get(i..i + 1).and_then(|d| u8::from_str_radix(d, 16).ok()); + let pair = |i: usize| h.get(i..i + 2).and_then(|d| u8::from_str_radix(d, 16).ok()); + match h.len() { + 3 => Some([digit(0)? * 17, digit(1)? * 17, digit(2)? * 17]), + 6 => Some([pair(0)?, pair(2)?, pair(4)?]), + _ => None, + } +} + +/// sRGB (components 0–1) to hue, saturation and value (0–1). +pub fn rgb_to_hsv([r, g, b]: [f32; 3]) -> [f32; 3] { + let (r, g, b) = (r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)); + let max = r.max(g).max(b); + let d = max - r.min(g).min(b); + if d <= 0.0 { + return [0.0, 0.0, max]; + } + let h = if max == r { + ((g - b) / d).rem_euclid(6.0) + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + [h / 6.0, d / max, max] +} + +/// Hue, saturation and value (0–1) to sRGB (components 0–1). +pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] { + let h = h.clamp(0.0, 1.0) * 6.0; + let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); + let f = |n: f32| { + let k = (n + h) % 6.0; + v - v * s * k.min(4.0 - k).clamp(0.0, 1.0) + }; + [f(5.0), f(3.0), f(1.0)] +} diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1b93658f2..779459fb8 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -364,7 +364,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa } } let mut c = tp.color; - if color_popup(ui, picker_id(key), sr.left_bottom(), &mut c) { + if widgets::color_popup(ui, picker_id(key), sr.left_bottom(), &mut c) { tool_options(app, json!({key: c})); paint(app, key, json!(c)); } @@ -445,12 +445,6 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t } } -/// sRGB components (0–1) as a colour. -fn srgb32(c: [f32; 3]) -> Color32 { - let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) -} - /// A Fill or Stroke swatch: its colour, a gradient (white to black), or None's red slash. fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: &Tokens) { let clip = p.with_clip_rect(r); @@ -460,7 +454,7 @@ fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: clip.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.5, Color32::from_rgb(0xe0, 0x30, 0x30))); } PaintKind::Solid => { - clip.rect_filled(r, 2.0, srgb32(paint.color)); + clip.rect_filled(r, 2.0, widgets::srgb32(paint.color)); } PaintKind::Linear => { let mut m = egui::Mesh::default(); @@ -555,28 +549,6 @@ fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { } } -/// A small colour picker popup bound to an RGB value. -pub fn color_popup(ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool { - let open_id = id.with("open"); - if !ui.data(|d| d.get_temp::(open_id).unwrap_or(false)) { - return false; - } - let mut changed = false; - let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - let mut col = srgb32(*c); - if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) { - *c = [col.r() as f32 / 255.0, col.g() as f32 / 255.0, col.b() as f32 / 255.0]; - changed = true; - } - }); - }); - if widgets::pressed_outside(ui.ctx(), &area.response) || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(open_id, false)); - } - changed -} - /// The ArtCraft mark as supplied in `docs/brand` (the README's and getartcraft.com's logo; used /// unmodified, see `docs/brand/LICENSE-brand.txt`). static ARTCRAFT_MARK: &[u8] = include_bytes!("../../../docs/brand/artcraft-mark.png"); diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index fdcaa27e6..387bfdd60 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1923,9 +1923,11 @@ mod tests { /// The interface text that has moved into this catalog. Add a panel here when you convert it; /// the tests below then keep it translated. const CONVERTED: &[(&str, &str)] = &[ + ("color_picker.rs", include_str!("../color_picker.rs")), ("credits.rs", include_str!("../credits.rs")), ("header.rs", include_str!("../header.rs")), ("menus.rs", include_str!("../menus.rs")), + ("widgets.rs", include_str!("../widgets.rs")), ("panels/anim_tools.rs", include_str!("../panels/anim_tools.rs")), ("panels/camera_tracker_ui.rs", include_str!("../panels/camera_tracker_ui.rs")), ("panels/comp_settings.rs", include_str!("../panels/comp_settings.rs")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dd0efc0ca..a64aca567 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -10,6 +10,7 @@ pub mod audio; pub mod automation; pub mod bench; +pub mod color_picker; pub mod control; pub mod credits; pub mod dock; diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 485e2e8b5..4e3c8007d 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -268,7 +268,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { }); ui.end_row(); ui.label(tr("Color")); - ui.color_edit_button_rgb(&mut d.color); + crate::widgets::srgb_color_button(ui, &mut d.color); ui.end_row(); ui.label(tr("Intensity")); ui.add(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %")); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 94d8e5a45..adab02474 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -353,7 +353,7 @@ fn prop_row( } app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; - if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); } } diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 26d458d4e..c46a5ad04 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -344,11 +344,10 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Color => { let c = v.as_color(); - let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]); - let r = egui::widgets::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::widgets::color_picker::Alpha::Opaque); + let mut rgb = [c[0], c[1], c[2]]; + let r = crate::widgets::srgb_color_button(ui, &mut rgb); if r.changed() { - let u = rgba.to_rgba_unmultiplied(); - out = Some(json!([u[0], u[1], u[2], u[3]])); + out = Some(json!([rgb[0], rgb[1], rgb[2], c[3]])); } r.rect } diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs index 51b146af4..a426f5a18 100644 --- a/crates/ui-egui/src/panels/fx_editors.rs +++ b/crates/ui-egui/src/panels/fx_editors.rs @@ -581,7 +581,7 @@ fn colorama_wheel( t.text_dim, ); let mut rgb = [col[0], col[1], col[2]]; - if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { let mut st = pal.stops.clone(); st[sel].1 = [rgb[0], rgb[1], rgb[2], col[3]]; new_stops = Some(st); diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 7738fbea7..6a06da8ab 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -111,9 +111,9 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option { (r.changed() && y != *x).then(|| json!(y)) } (KV::Color(c), _) => { - let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32); - let changed = egui::color_picker::color_edit_button_rgba(ui, &mut rgba, egui::color_picker::Alpha::Opaque).changed(); - changed.then(|| json!([rgba.r(), rgba.g(), rgba.b()])) + let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; + let changed = crate::widgets::srgb_color_button(ui, &mut rgb).changed(); + changed.then(|| json!(rgb)) } (KV::Gradient(_), _) => { ui.label(egui::RichText::new(tr("Edit in the Timeline")).weak()); diff --git a/crates/ui-egui/src/panels/paint_panels.rs b/crates/ui-egui/src/panels/paint_panels.rs index 62f270558..675bc1e16 100644 --- a/crates/ui-egui/src/panels/paint_panels.rs +++ b/crates/ui-egui/src/panels/paint_panels.rs @@ -102,11 +102,11 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("paint.color", fg, "Foreground color"); app.auto.add("paint.background", bg, "Background color"); let mut fc = rgb(o.color); - if crate::header::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) { + if crate::widgets::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) { set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]})); } let mut bc = rgb(o.background); - if crate::header::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) { + if crate::widgets::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) { set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]})); } let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0)); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 9e78ffbc0..48385f686 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -401,7 +401,7 @@ fn text_section( } app.auto.add(&format!("properties.text.{key}"), sw, label); let mut rgb = [c[0], c[1], c[2]]; - if crate::header::color_popup(ui, pop, sw.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, sw.left_bottom(), &mut rgb) { set(actions, json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("props-text-{key}")})); } p.text(pos2(sw.max.x + 8.0, sw.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 6a84d7616..992185173 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -38,6 +38,12 @@ fn hex(c: [u8; 3]) -> String { format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) } +/// sRGB components (0–1) as bytes. +fn srgb_bytes(c: [f32; 3]) -> [u8; 3] { + let col = crate::widgets::srgb32(c); + [col.r(), col.g(), col.b()] +} + fn reg(app: &mut EffectcraftApp, id: &str, r: &egui::Response, label: &str) { app.auto.add(id, r.rect, label); } @@ -178,8 +184,9 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } Kind::Color => { let c = crate::panels::viewer::hex_rgb(v.as_str().unwrap_or("")).unwrap_or([128, 128, 128]); - let mut rgb = c; - let r = ui.color_edit_button_srgb(&mut rgb); + let mut f = c.map(|v| v as f32 / 255.0); + let r = crate::widgets::srgb_color_button(ui, &mut f); + let rgb = if r.changed() { srgb_bytes(f) } else { c }; ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim)); if rgb != c { acts.push(Act::Set(key.into(), json!(hex(rgb)))); @@ -559,8 +566,9 @@ fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mu egui::Grid::new("settings-labels").num_columns(4).spacing([10.0, 4.0]).show(ui, |ui| { for (i, l) in labels.iter().enumerate() { let c = crate::panels::viewer::hex_rgb(l["color"].as_str().unwrap_or("")).unwrap_or([128, 128, 128]); - let mut rgb = c; - let r = ui.color_edit_button_srgb(&mut rgb); + let mut f = c.map(|v| v as f32 / 255.0); + let r = crate::widgets::srgb_color_button(ui, &mut f); + let rgb = if r.changed() { srgb_bytes(f) } else { c }; app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour"); if rgb != c { acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb)))); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 8e24a0835..ba352de4a 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -202,7 +202,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("character.{key}"), fs, label); let mut rgb = [c[0], c[1], c[2]]; - if crate::header::color_popup(ui, pop, fs.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, fs.left_bottom(), &mut rgb) { actions.push(json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("char-{key}")})); } p.text(pos2(fs.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7df3439be..3b078d20b 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3558,7 +3558,7 @@ fn value_editor( widgets::open_popup(ui, pop); } let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; - if crate::header::color_popup(ui, pop, r.left_bottom(), &mut rgb) { + if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) { set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); } } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9adf007f8..2af1fc74b 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2147,14 +2147,7 @@ pub(crate) fn minify_factor(smooth: bool, pixels: f64, screen: f64) -> usize { if smooth && k >= 1.0 { k.min(256.0) as usize } else { 1 } } -pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> { - let h = s.trim().trim_start_matches('#'); - if h.len() != 6 || !h.is_ascii() { - return None; - } - let p = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok(); - Some([p(0)?, p(2)?, p(4)?]) -} +pub(crate) use crate::widgets::hex_rgb; /// The Extended Viewer's render region for this frame (see [`ViewerState::extended`]): /// Settings ▸ 3D ▸ Extended Viewer on, the comp has 3D layers and the view is a custom 3D view diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f12e52be..b1f5a661e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -182,17 +182,8 @@ pub fn swatch(ui: &mut Ui, rect: Rect, c: [f32; 4], id: egui::Id, t: &Tokens) -> resp } -/// A colour button with egui's picker for a colour stored as sRGB components (0–1), as the -/// project stores colours (`color_edit_button_rgb` reads linear values and shows them lighter). -/// The value is only rewritten when the user picks a colour. -pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response { - let mut u = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); - let r = ui.color_edit_button_srgb(&mut u); - if r.changed() { - *c = u.map(|v| v as f32 / 255.0); - } - r -} +// The colour picker and its helpers (every colour input uses them). +pub use crate::color_picker::{color_popup, hex_rgb, hsv_to_rgb, rgb_to_hsv, srgb_color_button, srgb_hex, srgb32}; /// A twirl-down triangle; returns clicked. pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> Response { diff --git a/crates/ui-egui/tests/ui_color_hex.rs b/crates/ui-egui/tests/ui_color_hex.rs new file mode 100644 index 000000000..90eb19b8b --- /dev/null +++ b/crates/ui-egui/tests/ui_color_hex.rs @@ -0,0 +1,233 @@ +//! The colour picker every colour input opens: its hex field sets an effect's colour (CC Toner), +//! the dialogs' colour button stores sRGB (egui's own `color_edit_button_rgba` read the stored +//! sRGB values as linear, so mid-grey `#808080` was stored as `#373737`), the square, slider and +//! HSB / RGB fields set the colour, and Cancel / Escape / the original swatch restore it. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use effectcraft_ui_egui::widgets; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +const GREY: f32 = 128.0 / 255.0; + +fn click(h: &mut Harness<'_, S>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Click the open popup's Hex field and type `text` over the code it shows. +fn type_hex(h: &mut Harness<'_, S>, text: &str) { + let at = h.get_by_role_and_label(Role::TextInput, "#").rect().center(); + click(h, at); + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed, repeat: false, modifiers: Modifiers::COMMAND }); + } + h.step(); + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(3); +} + +fn close(c: [f32; 3], want: [f32; 3]) -> bool { + c.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4) +} + +#[test] +fn hex_code_sets_an_effect_colour() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Hex", "width": 320, "height": 180, "duration": 4})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#40b080"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layer": layer, "effect": "CC Toner"})).unwrap()["effects"][0].as_u64().unwrap(); + let uid = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().get("midtones").unwrap().uid; + s.state.selected_layers = vec![LayerId(layer)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + app.toggle_maximize(PanelKind::EffectControls); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(4); + let midtones = |h: &Harness<'_, EffectcraftApp>| { + let c = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(uid).unwrap().value.as_color(); + [c[0], c[1], c[2]] + }; + + let [x, y, w, ht] = h.state().auto.find(&format!("effectControls.prop.{uid}.value")).expect("the Midtones swatch").rect; + click(&mut h, pos2(x + w / 2.0, y + ht / 2.0)); + type_hex(&mut h, "#FF8000"); + assert!(close(midtones(&h), [1.0, GREY, 0.0]), "{:?}", midtones(&h)); + // A partial code doesn't apply; the short form does. + type_hex(&mut h, "#12"); + assert!(close(midtones(&h), [1.0, GREY, 0.0]), "{:?}", midtones(&h)); + type_hex(&mut h, "0f8"); + assert!(close(midtones(&h), [0.0, 1.0, 136.0 / 255.0]), "{:?}", midtones(&h)); +} + +/// A colour button in a harness of its own, its picker open. +fn picker(c: [f32; 3]) -> Harness<'static, [f32; 3]> { + let mut h = Harness::builder().with_size(vec2(560.0, 400.0)).build_ui_state( + |ui, c: &mut [f32; 3]| { + widgets::srgb_color_button(ui, c); + }, + c, + ); + h.run_steps(2); + let button = h.get_by_role(Role::ColorWell).rect().center(); + click(&mut h, button); + h +} + +fn press(h: &mut Harness<'_, [f32; 3]>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + } + h.run_steps(2); +} + +fn click_label(h: &mut Harness<'_, [f32; 3]>, label: &str) { + let at = h.get_by_label(label).rect().center(); + click(h, at); +} + +/// Type `text` into the first number field labelled `label` ("R:", "H:" …; "B:" is Brightness +/// before Blue, as in After Effects) and press Enter. +fn type_field(h: &mut Harness<'_, [f32; 3]>, label: &str, text: &str) { + let at = h.get_all_by_role_and_label(Role::SpinButton, label).next().expect("the field").rect().center(); + click(h, at); + press(h, Key::A, Modifiers::COMMAND); + h.input_mut().events.push(Event::Text(text.into())); + h.step(); + press(h, Key::Enter, Modifiers::NONE); +} + +fn near(c: [f32; 3], want: [f32; 3]) -> bool { + c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02) +} + +fn is_open(h: &Harness<'_, [f32; 3]>) -> bool { + h.query_by_role_and_label(Role::TextInput, "#").is_some() +} + +#[test] +fn the_dialog_colour_button_stores_srgb() { + let mut h = picker([1.0, 1.0, 1.0]); + type_hex(&mut h, "808080"); + assert!(close(*h.state(), [GREY; 3]), "{:?}", h.state()); + click_label(&mut h, "OK"); + assert!(!is_open(&h)); + assert!(close(*h.state(), [GREY; 3])); +} + +/// Changes apply as they are made; Cancel, Escape and the original swatch go back to the colour +/// the picker opened with, OK keeps the new one. +#[test] +fn cancel_escape_and_the_original_swatch_restore_the_colour() { + let red = [1.0, 0.0, 0.0]; + let mut h = picker(red); + type_hex(&mut h, "00FF00"); + assert!(close(*h.state(), [0.0, 1.0, 0.0])); + click_label(&mut h, "Cancel"); + assert!(!is_open(&h)); + assert!(close(*h.state(), red), "{:?}", h.state()); + + let button = h.get_by_role(Role::ColorWell).rect().center(); + click(&mut h, button); + type_hex(&mut h, "0000FF"); + press(&mut h, Key::Escape, Modifiers::NONE); + assert!(!is_open(&h)); + assert!(close(*h.state(), red), "{:?}", h.state()); + + click(&mut h, button); + type_hex(&mut h, "0000FF"); + click_label(&mut h, "Original color"); + assert!(is_open(&h), "going back to the original keeps the picker open"); + assert!(close(*h.state(), red), "{:?}", h.state()); + type_hex(&mut h, "FFFF00"); + click_label(&mut h, "OK"); + assert!(close(*h.state(), [1.0, 1.0, 0.0]), "{:?}", h.state()); +} + +/// With H on the slider the square is saturation × brightness; the hue is kept while the colour is +/// black, where RGB has none. HSB and RGB values can be typed. +#[test] +fn the_hsb_square_slider_and_fields_set_the_colour() { + let mut h = picker([1.0, 0.0, 0.0]); + let square = h.get_by_label("Color field").rect(); + let slider = h.get_by_label("Color slider").rect(); + + click(&mut h, pos2(square.center().x, square.max.y - 1.0)); + assert!(near(*h.state(), [0.0; 3]), "the bottom edge is black: {:?}", h.state()); + click(&mut h, slider.center()); + assert!(near(*h.state(), [0.0; 3]), "a new hue leaves black black: {:?}", h.state()); + click(&mut h, pos2(square.max.x - 1.0, square.min.y + 1.0)); + assert!(near(*h.state(), [0.0, 1.0, 1.0]), "the hue chosen while black is kept: {:?}", h.state()); + + type_field(&mut h, "R:", "64"); + assert!((h.state()[0] - 64.0 / 255.0).abs() < 1e-4 && near(*h.state(), [64.0 / 255.0, 1.0, 1.0]), "{:?}", h.state()); + type_field(&mut h, "H:", "0"); + type_field(&mut h, "S:", "100"); + type_field(&mut h, "B:", "50"); + assert!(near(*h.state(), [0.5, 0.0, 0.0]), "{:?}", h.state()); +} + +/// With R chosen the slider sets red and the square blue (across) and green (up), as in After +/// Effects; the choice is remembered by the next picker. +#[test] +fn choosing_r_puts_red_on_the_slider_and_blue_green_on_the_square() { + let mut h = picker([0.0, 0.0, 0.0]); + let radio = h.get_by_role_and_label(Role::RadioButton, "R:").rect().center(); + click(&mut h, radio); + let square = h.get_by_label("Color field").rect(); + let slider = h.get_by_label("Color slider").rect(); + click(&mut h, pos2(slider.center().x, slider.min.y + 1.0)); + assert!(near(*h.state(), [1.0, 0.0, 0.0]), "the top of the slider is full red: {:?}", h.state()); + click(&mut h, pos2(square.max.x - 1.0, square.max.y - 1.0)); + assert!(near(*h.state(), [1.0, 0.0, 1.0]), "bottom right adds full blue: {:?}", h.state()); + click(&mut h, pos2(square.min.x + 1.0, square.min.y + 1.0)); + assert!(near(*h.state(), [1.0, 1.0, 0.0]), "top left is full green, no blue: {:?}", h.state()); + click_label(&mut h, "OK"); + + let button = h.get_by_role(Role::ColorWell).rect().center(); + click(&mut h, button); + let slider = h.get_by_label("Color slider").rect(); + click(&mut h, pos2(slider.center().x, slider.max.y - 1.0)); + assert!(near(*h.state(), [0.0, 1.0, 0.0]), "the slider still sets red: {:?}", h.state()); +} + +#[test] +fn hex_codes_parse() { + assert_eq!(widgets::hex_rgb("#ff8000"), Some([255, 128, 0])); + assert_eq!(widgets::hex_rgb(" FF8000 "), Some([255, 128, 0])); + assert_eq!(widgets::hex_rgb("#0f8"), Some([0, 255, 136])); + for bad in ["", "#", "#12", "#12345", "#1234567", "#gg0000", "#ffé000"] { + assert_eq!(widgets::hex_rgb(bad), None, "{bad}"); + } + assert_eq!(widgets::srgb_hex([1.0, GREY, 0.0]), "#FF8000"); +} + +#[test] +fn hsv_round_trips() { + for c in [[1.0, 0.0, 0.0], [0.0, 1.0, 1.0], [GREY, 0.25, 0.75], [0.2, 0.2, 0.2], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]] { + let back = widgets::hsv_to_rgb(widgets::rgb_to_hsv(c)); + assert!(close(back, c), "{c:?} -> {back:?}"); + } + assert!(close(widgets::hsv_to_rgb([0.5, 1.0, 1.0]), [0.0, 1.0, 1.0])); +} + +/// The fields round as the hex code does (0.7 × 255 = 178.5 shows as 179, `B3`), and a hue just +/// under 360° shows as 0°. +#[test] +fn the_fields_agree_with_the_hex_code() { + let h = picker([1.0, 0.699, 0.7]); + let value = |label: &str, nth: usize| h.get_all_by_role_and_label(Role::SpinButton, label).nth(nth).and_then(|n| n.value()).unwrap_or_default(); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "#").value().as_deref(), Some("FFB2B3")); + assert_eq!(value("G:", 0), "178"); + assert_eq!(value("B:", 1), "179"); + assert_eq!(value("H:", 0), "0°"); +} From 4de11be339effc75edc2bbd2996488640e748557 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:50:32 +0000 Subject: [PATCH 17/23] Time: in Frames style a typed number is a frame count (#610 follow-up) parse_timecode reads bare digits as timecode pairs, so 00100 went to frame 30 (1 s) instead of frame 100. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/time.rs | 11 ++++++++--- crates/engine/src/tests_timeline.rs | 17 ++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 811976021..f98ea5edb 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -43,12 +43,17 @@ pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> let relative = text.trim().starts_with('+') || text.trim().starts_with('-'); if relative { Ok(parsed) + } else if st.time_display == TimeDisplayStyle::Frames + && let Ok(frame) = text.trim().parse::() + { + // Displayed digits are a plain frame count (00100 is frame 100, not 1 s) plus + // Start Numbering Frames At. + Ok(frame.saturating_sub(st.frame_start)) } else if st.time_display == TimeDisplayStyle::Frames { - // Displayed digits are composition frame plus Start Numbering Frames At. - Ok(parsed - st.frame_start) + Ok(parsed.saturating_sub(st.frame_start)) } else { // Displayed timecode includes the composition Start Timecode offset. - Ok(parsed - fr.frame_at(comp.display_start)) + Ok(parsed.saturating_sub(fr.frame_at(comp.display_start))) } } } diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 88f7b9bd1..a741f5ade 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -413,11 +413,7 @@ fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() { #[test] fn time_set_keeps_the_frame_shown_in_the_current_time_field() { let mut s = Session::default(); - s.execute( - "comp.new", - json!({"name": "Example", "width": 64, "height": 64, "frameRate": 30, "duration": 0.6, "startTimecode": "0:00:00:05"}), - ) - .unwrap(); + s.execute("comp.new", json!({"name": "Example", "width": 64, "height": 64, "frameRate": 30, "duration": 0.6, "startTimecode": "0:00:00:05"})).unwrap(); let r = s.execute("time.set", json!({"frame": 0})).unwrap(); assert_eq!(r["display"], "0:00:00:05"); let r = s.execute("time.set", json!({"timecode": "0:00:00:05"})).unwrap(); @@ -433,6 +429,17 @@ fn time_set_keeps_the_frame_shown_in_the_current_time_field() { assert_eq!(r["frame"], 13); } +/// Frames display style: typed digits are a frame count, so 00100 is frame 100, not 1 s (#530). +#[test] +fn time_set_reads_frames_style_digits_as_a_frame_count() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Long", "width": 64, "height": 64, "frameRate": 30, "duration": 10})).unwrap(); + s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap(); + let r = s.execute("time.set", json!({"timecode": "00100"})).unwrap(); + assert_eq!(r["frame"], 100); + assert_eq!(r["display"], "00100"); +} + #[test] fn separate_dimensions_split_and_rejoin() { let (mut s, l) = setup(); From ff56c5ad24c581fe9e5285a9a53ff923d2f7efd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:50:32 +0000 Subject: [PATCH 18/23] Audio: the short preview buffer is Linux-only and within the device's range (#614 follow-up) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- apps/effectcraft/src/audio_out.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index 7c26db51f..7d0ec4b28 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -152,7 +152,15 @@ impl AudioDevice for CpalOut { }; let mut cfg: cpal::StreamConfig = sup.config(); cfg.sample_rate = cpal::SampleRate(rate); - cfg.buffer_size = cpal::BufferSize::Fixed(preferred_buffer_frames(rate)); + // A short period only where the default one is long (ALSA/PulseAudio on Linux, #554), + // and only within what the device reports it takes. + if cfg!(target_os = "linux") { + let want = preferred_buffer_frames(rate); + cfg.buffer_size = match sup.buffer_size() { + cpal::SupportedBufferSize::Range { min, max } if *min <= *max => cpal::BufferSize::Fixed(want.clamp(*min, *max)), + _ => cpal::BufferSize::Default, + }; + } let build = |cfg: &cpal::StreamConfig| match sup.sample_format() { cpal::SampleFormat::F32 => run::(&dev, cfg, feed.clone(), latency.clone(), map), cpal::SampleFormat::I16 => run::(&dev, cfg, feed.clone(), latency.clone(), map), From 58e91fafbfdf70cb3d17a3f2964b0256d0d1ce53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:50:32 +0000 Subject: [PATCH 19/23] Viewer: the locked viewer shares the main viewer's display key (#619 follow-up) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/viewer.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 88995483e..dbf450340 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -523,9 +523,7 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo .then(|| app.session.state.views3d.get(&lv.comp).cloned().unwrap_or_default().cam(lv.view, comp.width as f64, comp.height as f64).state()); let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session); // The display conversion's inputs, keyed as in the main viewer's texture caches. - let sim = app.session.state.viewer.simulation; - let custom = if sim.profile == effectcraft_engine::viewer::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() }; - let dc_key = format!("{cam:?}{}{sim:?}{}{custom}", dc.is_some(), app.session.prefs.previews.display_profile); + let dc_key = format!("{cam:?}{}", super::viewer_tools::display_key(app, dc.is_some())); let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); From 72b2e9774e448d85fe8872342cfef53eb96a218a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:51:45 +0000 Subject: [PATCH 20/23] Dock: test the empty-dock reopen with the reporter's Minimal workspace (#629 follow-up, test from #630) Co-Authored-By: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/dock.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 8785aeb29..ba5f5eaa7 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1456,6 +1456,27 @@ mod tests { assert_eq!(PanelKind::from_name("effects & presets"), Some(PanelKind::EffectsPresets)); } + /// Ported from #630 (Ni-zav): the reporter's reproduction on the Minimal workspace. + #[test] + fn window_panels_reopen_after_last_panel_is_closed() { + use PanelKind::*; + for reopen in [Composition, Timeline, Project] { + let mut d = workspace("Minimal"); + d.close(Composition); + d.close(Timeline); + assert_eq!(d.panel_count(), 0); + d.open_near(reopen, EffectsPresets); + assert_eq!(d.panel_count(), 1); + assert!(d.is_visible(reopen), "{reopen:?} must be visible"); + } + let mut d = tabs(&[Audio], 0); + d.open_near(History, Project); + assert_eq!(d, tabs(&[Audio, History], 1)); + let mut d = workspace("Minimal"); + d.open_near(EffectsPresets, Timeline); + assert_eq!(d.path_of(EffectsPresets), d.path_of(Timeline)); + } + #[test] fn closing_earlier_inactive_tab_keeps_the_shown_panel() { use PanelKind::*; From 7a95a0d138c8eafd1156c1ba7aa0e627c5f68f4a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:51:45 +0000 Subject: [PATCH 21/23] Project: a single unselected folder or data-file drag still reports why; index math saturates (#634 follow-up) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/mod.rs | 4 +++- crates/ui-egui/src/panels/timeline.rs | 2 +- crates/ui-egui/src/panels/viewer_drop.rs | 21 ++++++++++----------- crates/ui-egui/tests/ui_drag_drop.rs | 14 ++++++++------ 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 76691dce1..79ef9c2bc 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -88,7 +88,9 @@ pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec let candidates: Vec = if selected.iter().any(|id| id.0 == dragged) { selected.iter().map(|id| id.0).collect() } else { - vec![dragged] + // One unselected item goes through as is, so a folder or data file still gets the + // engine's error instead of a silent no-op. + return vec![dragged]; }; candidates .into_iter() diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 32c52b47a..f91987c08 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2964,7 +2964,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::panels::DragPayload::Item(id) => { // Insert each selected item at the drop point, in selection order. for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() { - actions.push(("layer.addItem".into(), json!({"item": item, "index": index + offset, "time": time.clone()}))); + actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()}))); } } crate::panels::DragPayload::Files(paths) => { diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index dec8d3802..2d42eb2c9 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -24,12 +24,15 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain let stroke = Stroke::new(2.0, app.tokens.accent); let at = map.to_comp(ptr); let actions: Vec<(&str, Value)> = match payload.as_ref() { - DragPayload::Effect(effect) => layer_at(ectx, at).map(|l| { - if let Some((_, q, _)) = layer_quad(ectx, l) { - painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); - } - ("effect.apply", json!({"effect": effect, "layers": [l.id.0]})) - }).into_iter().collect(), + DragPayload::Effect(effect) => layer_at(ectx, at) + .map(|l| { + if let Some((_, q, _)) = layer_quad(ectx, l) { + painter.add(egui::Shape::closed_line(q.iter().map(|c| map.to_screen(*c)).collect(), stroke)); + } + ("effect.apply", json!({"effect": effect, "layers": [l.id.0]})) + }) + .into_iter() + .collect(), DragPayload::Item(item) => { // The layer's frame, centred on the pointer. if let Some([w, h]) = app.session.project.item(ItemId(*item)).and_then(|it| frame_size(&it.kind, ectx.comp.pixel_aspect)) { @@ -37,11 +40,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain } // New layers go above the current selection, so insert in reverse // to preserve the order of the dragged Project items. - crate::panels::project_drop_items(app, *item) - .into_iter() - .rev() - .map(|item| ("layer.addItem", json!({"item": item, "position": at}))) - .collect() + crate::panels::project_drop_items(app, *item).into_iter().rev().map(|item| ("layer.addItem", json!({"item": item, "position": at}))).collect() } DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))], DragPayload::Property { .. } => vec![], diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 56f9ffd72..3dee2727e 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -129,10 +129,11 @@ fn project_items_dropped_on_the_viewer_land_under_the_pointer() { fn dragging_selected_project_items_adds_every_item_to_the_timeline() { let (mut h, clip) = harness(); let main = h.state().session.active_comp_id().unwrap().0; - let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"] + .as_u64() + .unwrap(); h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); - h.state_mut().session.state.project_selection = - vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; + h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; h.run_steps(3); let from = rect(&h, &format!("project.item.{clip}.name")).center(); let top = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Top"))); @@ -145,10 +146,11 @@ fn dragging_selected_project_items_adds_every_item_to_the_timeline() { fn dragging_selected_project_items_adds_every_item_to_the_viewer() { let (mut h, clip) = harness(); let main = h.state().session.active_comp_id().unwrap().0; - let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let other = h.state_mut().session.execute("comp.new", json!({"name": "Other", "width": 80, "height": 60, "frameRate": 30, "duration": 1})).unwrap()["comp"] + .as_u64() + .unwrap(); h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); - h.state_mut().session.state.project_selection = - vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; + h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip), effectcraft_engine::project::ItemId(other)]; h.run_steps(3); let from = rect(&h, &format!("project.item.{clip}.name")).center(); let to = comp_to_screen(&h.ctx, [80.0, 45.0]).unwrap(); From 7210b37bb2b719384a607e20869f92915e08b90b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:51:45 +0000 Subject: [PATCH 22/23] Format effect.list's parameter metadata (#631 follow-up) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/effect.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index a5561cee9..3c597ebd2 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -487,7 +487,12 @@ pub fn specs() -> Vec { cmd!("effect.copy", "Copy Effects", [], None, "{layer?, effect? | effects?: [uid|name|index]} (default: the selected effects)", has_layers, copy), cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste), cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset), - crate::query!("effect.list", "List Effects", "{filter?} → [{params: [{id, name, type, default, options?, min?, max?, sliderMin?, sliderMax?, decimals?}]}]; popup indices are zero-based (JSX uses one-based values)", list), + crate::query!( + "effect.list", + "List Effects", + "{filter?} → [{params: [{id, name, type, default, options?, min?, max?, sliderMin?, sliderMax?, decimals?}]}]; popup indices are zero-based (JSX uses one-based values)", + list + ), crate::query!( "effect.warning", "Effect Warning", @@ -524,11 +529,14 @@ mod list_metadata_tests { let menu = &menus[0]["params"][0]; assert_eq!(menu["type"], "enum"); assert_eq!(menu["default"], 0); - assert_eq!(menu["options"], json!([ - {"value": 0, "label": "Item 1"}, - {"value": 1, "label": "Item 2"}, - {"value": 2, "label": "Item 3"} - ])); + assert_eq!( + menu["options"], + json!([ + {"value": 0, "label": "Item 1"}, + {"value": 1, "label": "Item 2"}, + {"value": 2, "label": "Item 3"} + ]) + ); let sliders = s.execute("effect.list", json!({"filter": "ec.control.slider"})).unwrap(); assert_eq!(sliders.as_array().map(Vec::len), Some(1)); let slider = &sliders[0]["params"][0]; From 485e4bb59dcf246ca13148153e0886472081c0a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:17:26 +0000 Subject: [PATCH 23/23] Interface: Hangul is probed after the other scripts, so a catch-all face never draws Arabic (#616 follow-up) Without a Korean font the Hangul lookup can land on Unifont, which also covers Arabic but can't join it; registered first, it took Arabic too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/theme.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b12251ce0..928dca9b4 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -339,8 +339,8 @@ const SCRIPT_PROBES: &[(char, &str)] = &[ /// Japanese-locale machine would be drawn with the Japanese face. The interface language is the /// better signal, so the probe for its script comes first — kana for Japanese, a Han character for /// the Chinese catalogs. Both scripts are registered, so file, layer and template names in the -/// other one still render. Hangul comes next, since the Japanese and Chinese faces may lack it -/// (Hiragino Sans on macOS has none); then [`SCRIPT_PROBES`]. The web build has no system fonts +/// other one still render; then [`SCRIPT_PROBES`], then Hangul, since the Japanese and Chinese faces +/// may lack it (Hiragino Sans on macOS has none). The web build has no system fonts /// and installs nothing. fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) -> Vec { #[cfg(target_arch = "wasm32")] @@ -355,16 +355,17 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // language's face draws them. const KANA: (char, &str) = ('あ', "japanese-system"); const HAN: (char, &str) = ('文', "chinese-system"); - // Hangul is probed after kana and Han so their order is unchanged; a face that already covers it - // is skipped as seen. + // Hangul is probed last: with no Korean font installed the lookup can land on a catch-all + // face (Unifont) that also covers Arabic or Indic text but can't shape it, so the + // script-specific faces must be registered ahead of it. A face already seen is skipped. const HANGUL: (char, &str) = ('한', "korean-system"); // Han text follows the interface language, not the operating system's locale. fonts::set_cjk_locale(cjk_locale(language)); let base = fonts::resolve("Inter", "Regular").face; - let cjk = if is_chinese(language) { [HAN, KANA, HANGUL] } else { [KANA, HAN, HANGUL] }; + let cjk = if is_chinese(language) { [HAN, KANA] } else { [KANA, HAN] }; let mut names = Vec::new(); let mut seen = Vec::new(); - for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES) { + for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) { let id = fonts::fallback_for(probe, base); if seen.contains(&id) { continue;