diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index a7929b6d9..7d0ec4b28 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,44 @@ 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), + // 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), + 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 +218,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()); + } +} 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/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/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index fe0d42100..3c597ebd2 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -416,7 +416,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)) @@ -470,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?}", 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", @@ -494,3 +516,36 @@ 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()); + } +} 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/commands/time.rs b/crates/engine/src/commands/time.rs index 9ed41f47c..f98ea5edb 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -38,7 +38,24 @@ 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 + && 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 { + Ok(parsed.saturating_sub(st.frame_start)) + } else { + // Displayed timecode includes the composition Start Timecode offset. + Ok(parsed.saturating_sub(fr.frame_at(comp.display_start))) + } + } } } 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); +} diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index de746f668..a741f5ade 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(); @@ -391,6 +410,36 @@ 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); +} + +/// 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(); 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/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")]; 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())]); 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()]; 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/dock.rs b/crates/ui-egui/src/dock.rs index 65bca8803..ba5f5eaa7 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::*; @@ -1419,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::*; 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 9cb9d7365..c0385aa47 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -1927,9 +1927,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 7cd7a2a54..6c4053c01 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/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/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/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 6113c8662..afc0fc8ac 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(crate::widgets::drag_value(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 c264e74ed..ce6121a76 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 c6b6abbe3..1366c2ae8 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/mod.rs b/crates/ui-egui/src/panels/mod.rs index ab5a1c8f7..79ef9c2bc 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -78,6 +78,32 @@ 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 { + // 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() + .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/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 e5d5d165a..dbeeb1a7a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -395,7 +395,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 c79451785..385febefe 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/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"); + } } diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index fa64860ae..1ed3f21e0 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -198,7 +198,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..f91987c08 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.saturating_add(offset), "time": time.clone()}))); + } + } crate::panels::DragPayload::Files(paths) => { actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time}))) } @@ -3558,7 +3563,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..081e136dc 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -522,10 +522,12 @@ 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 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(); - (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"); @@ -1044,7 +1046,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] @@ -2147,14 +2151,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/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index 31d426b36..2d42eb2c9 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -23,32 +23,37 @@ 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() { - 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]})) - }), + 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/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index e704e0a60..c94f94f56 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/src/theme.rs b/crates/ui-egui/src/theme.rs index a37bead64..928dca9b4 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; 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")] { @@ -354,13 +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 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] } 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; @@ -597,6 +602,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(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 81b976d2d..0b811ffbd 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -192,17 +192,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_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 { 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°"); +} diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 9738fef28..3dee2727e 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -123,6 +123,46 @@ 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] 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); +} 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]); +}