diff --git a/crates/engine/src/commands/align.rs b/crates/engine/src/commands/align.rs new file mode 100644 index 00000000..1c8abaef --- /dev/null +++ b/crates/engine/src/commands/align.rs @@ -0,0 +1,285 @@ +//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`. +//! +//! Layers are aligned by their content bounds in comp space (source size, text glyphs, shape +//! contents through the layer's transform and its parents'). Each layer moves by a comp-space +//! offset that is converted into its parent's space before it is added to Position (Separate +//! Dimensions writes X/Y Position), at the current time (a key when Position is animated). All +//! moves are one undo step. + +use effectcraft_geom::{Mat4, Vec3}; +use effectcraft_keyframe::Value as KValue; +use effectcraft_project::{ItemId, LayerId}; +use effectcraft_render::EvalCtx; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// A layer's comp-space bounds and how a comp-space offset maps into its Position. +struct Placed { + id: LayerId, + /// `[x0, y0, x1, y1]` in comp space. + b: [f64; 4], + /// Comp space → the parent's space (the space Position lives in). + to_parent: Mat4, +} + +fn placed(s: &Session, cid: ItemId, ids: &[LayerId]) -> Result> { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, s.time()) }; + let mut out = vec![]; + for &id in ids { + let Some(l) = comp.layer(id) else { continue }; + if l.transform().is_none() { + continue; + } + let Some(b) = effectcraft_render::content_bounds(&ctx, l) else { continue }; + let w = ctx.world_matrix(l); + let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| w.apply(Vec3::from([p[0], p[1], 0.0]))); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in pts { + x0 = x0.min(p.x); + y0 = y0.min(p.y); + x1 = x1.max(p.x); + y1 = y1.max(p.y); + } + // Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer). + let mut par = Mat4::IDENTITY; + let mut cur = l.parent; + let mut guard = 0; + while let Some(pid) = cur { + guard += 1; + let Some(p) = comp.layer(pid).filter(|_| guard < 64) else { break }; + par = ctx.local_matrix(p) * par; + cur = p.parent; + } + let to_parent = par.inverse().unwrap_or(Mat4::IDENTITY); + out.push(Placed { id, b: [x0, y0, x1, y1], to_parent }); + } + Ok(out) +} + +/// Move layers by comp-space offsets, as one undo step. +fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>) -> Result { + let t = s.time(); + let moved: Vec = moves.iter().filter(|m| m.2[0].abs() > 1e-9 || m.2[1].abs() > 1e-9).map(|m| m.0.0).collect(); + s.edit(label, None, |proj, _| { + for (lid, to_parent, d) in &moves { + if d[0].abs() <= 1e-9 && d[1].abs() <= 1e-9 { + continue; + } + let o = to_parent.apply(Vec3::from([0.0, 0.0, 0.0])); + let q = to_parent.apply(Vec3::from([d[0], d[1], 0.0])); + let dp = [q.x - o.x, q.y - o.y, q.z - o.z]; + let l = layer_mut(proj, cid, *lid)?; + let lt = l.layer_time(t); + let three = l.is_3d(); + let tr = l.transform_mut().ok_or_else(|| bad("layer.align", "layer has no transform"))?; + if tr.get("positionX").is_some() { + let dims = if three { 3 } else { 2 }; + for (k, m) in ["positionX", "positionY", "positionZ"].iter().enumerate().take(dims) { + if let Some(pr) = tr.get_mut(m) { + let v = pr.value_at(lt).as_f64() + dp[k]; + pr.set_value_at(lt, KValue::Scalar(v)); + } + } + } else if let Some(pr) = tr.get_mut("position") { + let cur = pr.value_at(lt); + let a = cur.to_json(); + let get = |k: usize| a.get(k).and_then(Value::as_f64).unwrap_or(0.0); + let nv = json!([get(0) + dp[0], get(1) + dp[1], get(2) + if three { dp[2] } else { 0.0 }]); + if let Some(v) = cur.coerce_json(&nv) { + pr.set_value_at(lt, v); + } + } + } + Ok(()) + })?; + Ok(json!({"moved": moved})) +} + +const EDGES: &str = "left|hcenter|right|top|vcenter|bottom"; + +/// The coordinate of an edge (or centre) of bounds; `true` for horizontal edges. +fn edge_of(b: &[f64; 4], edge: &str) -> Option<(f64, bool)> { + Some(match edge { + "left" => (b[0], true), + "hcenter" | "horizontalCenter" => ((b[0] + b[2]) * 0.5, true), + "right" => (b[2], true), + "top" => (b[1], false), + "vcenter" | "verticalCenter" => ((b[1] + b[3]) * 0.5, false), + "bottom" => (b[3], false), + _ => return None, + }) +} + +fn align(s: &mut Session, p: &Value) -> Result { + let c = "layer.align"; + let edge = str_p(p, "edge").ok_or_else(|| bad(c, format!("missing `edge` ({EDGES})")))?; + let to = str_p(p, "to").unwrap_or("composition"); + let (cid, ids) = layers_p(s, p)?; + let items = placed(s, cid, &ids)?; + if items.is_empty() { + return Err(bad(c, "no layers with bounds to align")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let target: [f64; 4] = match to { + "composition" | "comp" => [0.0, 0.0, comp.width as f64, comp.height as f64], + "selection" => { + if items.len() < 2 { + return Err(bad(c, "aligning to the selection needs two or more layers")); + } + items.iter().fold([f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY], |a, it| { + [a[0].min(it.b[0]), a[1].min(it.b[1]), a[2].max(it.b[2]), a[3].max(it.b[3])] + }) + } + x => return Err(bad(c, format!("unknown `to` `{x}` (selection|composition)"))), + }; + let (goal, horiz) = edge_of(&target, edge).ok_or_else(|| bad(c, format!("unknown edge `{edge}` ({EDGES})")))?; + let moves = items + .into_iter() + .map(|it| { + let (v, _) = edge_of(&it.b, edge).unwrap_or((goal, horiz)); + let d = if horiz { [goal - v, 0.0] } else { [0.0, goal - v] }; + (it.id, it.to_parent, d) + }) + .collect(); + apply_moves(s, cid, "Align Layers", moves) +} + +fn distribute(s: &mut Session, p: &Value) -> Result { + let c = "layer.distribute"; + let mode = str_p(p, "mode").or(str_p(p, "edge")).ok_or_else(|| bad(c, format!("missing `mode` ({EDGES})")))?; + let (cid, ids) = layers_p(s, p)?; + let mut items = placed(s, cid, &ids)?; + if items.len() < 3 { + return Err(bad(c, "distributing needs three or more layers")); + } + edge_of(&items[0].b, mode).ok_or_else(|| bad(c, format!("unknown mode `{mode}` ({EDGES})")))?; + let key = |it: &Placed| edge_of(&it.b, mode).map_or(0.0, |e| e.0); + items.sort_by(|a, b| key(a).total_cmp(&key(b))); + let (first, last) = (key(&items[0]), key(&items[items.len() - 1])); + let n = items.len() - 1; + let horiz = edge_of(&items[0].b, mode).is_some_and(|e| e.1); + let moves = items + .iter() + .enumerate() + .map(|(i, it)| { + let goal = first + (last - first) * i as f64 / n as f64; + let dv = goal - key(it); + (it.id, it.to_parent, if horiz { [dv, 0.0] } else { [0.0, dv] }) + }) + .collect(); + apply_moves(s, cid, "Distribute Layers", moves) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "layer.align", + "Align Layers", + [], + None, + "{edge: left|hcenter|right|top|vcenter|bottom, to?: composition|selection (default composition), layers?}", + has_layers, + align + ), + cmd!( + "layer.distribute", + "Distribute Layers", + [], + None, + "{mode: left|hcenter|right|top|vcenter|bottom (edges or centres), layers?}", + has_layers, + distribute + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + s + } + + fn solid(s: &mut Session, name: &str, w: u32, h: u32, pos: [f64; 2]) -> u64 { + let id = s.execute("layer.newSolid", json!({"name": name, "width": w, "height": h})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": [pos[0], pos[1], 0.0]})).unwrap(); + id + } + + fn bounds(s: &Session, id: u64) -> [f64; 4] { + let cid = s.active_comp_id().unwrap(); + placed(s, cid, &[LayerId(id)]).unwrap()[0].b + } + + #[test] + fn align_to_composition_and_selection_is_one_undo_step() { + let mut s = session(); + let a = solid(&mut s, "A", 50, 40, [100.0, 100.0]); + let b = solid(&mut s, "B", 80, 20, [250.0, 180.0]); + s.execute("layer.align", json!({"edge": "left", "layers": [a, b]})).unwrap(); + assert!(bounds(&s, a)[0].abs() < 1e-6 && bounds(&s, b)[0].abs() < 1e-6); + // One step undoes both. + s.execute("edit.undo", json!({})).unwrap(); + assert!((bounds(&s, a)[0] - 75.0).abs() < 1e-6); + assert!((bounds(&s, b)[0] - 210.0).abs() < 1e-6); + s.execute("layer.align", json!({"edge": "bottom", "to": "composition", "layers": [a, b]})).unwrap(); + assert!((bounds(&s, a)[3] - 300.0).abs() < 1e-6 && (bounds(&s, b)[3] - 300.0).abs() < 1e-6); + s.execute("edit.undo", json!({})).unwrap(); + // Selection: to the selection's right edge (B's) and vertical centre. + s.execute("layer.align", json!({"edge": "right", "to": "selection", "layers": [a, b]})).unwrap(); + assert!((bounds(&s, a)[2] - 290.0).abs() < 1e-6 && (bounds(&s, b)[2] - 290.0).abs() < 1e-6); + s.execute("layer.align", json!({"edge": "vcenter", "to": "selection", "layers": [a, b]})).unwrap(); + let (ca, cb) = (bounds(&s, a), bounds(&s, b)); + assert!(((ca[1] + ca[3]) / 2.0 - (cb[1] + cb[3]) / 2.0).abs() < 1e-6); + assert!(s.execute("layer.align", json!({"edge": "left", "to": "selection", "layers": [a]})).is_err()); + assert!(s.execute("layer.align", json!({"edge": "diagonal", "layers": [a]})).is_err()); + } + + #[test] + fn align_honours_parent_scale_and_rotation() { + let mut s = session(); + let parent = solid(&mut s, "P", 10, 10, [200.0, 150.0]); + s.execute("prop.set", json!({"layer": parent, "path": "transform/scale", "value": [200.0, 200.0, 100.0]})).unwrap(); + s.execute("prop.set", json!({"layer": parent, "path": "transform/rotation", "value": 90.0})).unwrap(); + let child = solid(&mut s, "C", 20, 10, [30.0, 10.0]); + s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap(); + s.execute("layer.align", json!({"edge": "top", "layers": [child]})).unwrap(); + assert!(bounds(&s, child)[1].abs() < 1e-6, "{:?}", bounds(&s, child)); + s.execute("layer.align", json!({"edge": "hcenter", "layers": [child]})).unwrap(); + let b = bounds(&s, child); + assert!(((b[0] + b[2]) / 2.0 - 200.0).abs() < 1e-6 && b[1].abs() < 1e-6); + } + + #[test] + fn distribute_edges_and_centres() { + let mut s = session(); + let a = solid(&mut s, "A", 20, 20, [50.0, 50.0]); + let b = solid(&mut s, "B", 60, 20, [90.0, 100.0]); + let c = solid(&mut s, "C", 20, 20, [350.0, 200.0]); + s.execute("layer.distribute", json!({"mode": "hcenter", "layers": [a, b, c]})).unwrap(); + let cx = |id| { + let b = bounds(&s, id); + (b[0] + b[2]) / 2.0 + }; + assert!((cx(b) - 200.0).abs() < 1e-6 && (cx(a) - 50.0).abs() < 1e-6 && (cx(c) - 350.0).abs() < 1e-6); + s.execute("layer.distribute", json!({"mode": "top", "layers": [a, b, c]})).unwrap(); + assert!((bounds(&s, b)[1] - 115.0).abs() < 1e-6); + assert!(s.execute("layer.distribute", json!({"mode": "top", "layers": [a, b]})).is_err()); + } + + #[test] + fn separated_position() { + let mut s = session(); + let a = solid(&mut s, "A", 40, 40, [100.0, 100.0]); + s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap(); + assert!(s.active_comp().unwrap().layers.iter().any(|l| l.transform().is_some_and(|t| t.get("positionX").is_some()))); + s.execute("layer.align", json!({"edge": "right", "layers": [a]})).unwrap(); + assert!((bounds(&s, a)[2] - 400.0).abs() < 1e-6); + } +} diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 7abd2923..01dd27f1 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -36,7 +36,14 @@ pub fn specs() -> Vec { fe!("app.templates", "Templates", [], None, "{kind: renderSettings|outputModule}", always), fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?}", always), // Preview. - fe!("playback.toggle", "Play Current Preview", ["Composition", "Preview"], Some("Space"), "{}", has_comp), + fe!( + "playback.toggle", + "Play Current Preview", + ["Composition", "Preview"], + Some("Space"), + "{shortcut?: spacebar|shiftSpacebar|numpad0|shiftNumpad0|altNumpad0 (whose Preview panel options to use; default spacebar)}", + has_comp + ), fe!("playback.cacheWhenIdle", "Cache Frames When Idle", ["Composition", "Preview"], None, "{value?}", always), fe!("playback.audio", "Audio", ["Composition", "Preview"], None, "{value?: include audio in previews}", always), // Viewer. diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 9ef10e09..c2adf968 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -1,6 +1,7 @@ //! The command registry. Ids follow After Effects' menu structure; every menu item, panel button, //! shortcut and viewer/timeline gesture maps to one of these. +mod align; mod align_data; mod anim; pub mod anim_tools; @@ -182,9 +183,11 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(autotrace::specs()); v.extend(scene_detect::specs()); v.extend(align_data::specs()); + v.extend(align::specs()); v.extend(content_fill::specs()); v.extend(stubs::specs()); v.extend(crate::learn::specs()); + v.extend(crate::preview::specs()); v }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 71d20f98..64733d44 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -24,6 +24,7 @@ pub mod media_cache; pub mod menus; pub mod offload; pub mod prefs; +pub mod preview; pub mod psd_import; pub mod render_queue; pub mod roto; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 8a484601..8e73c0ba 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -281,6 +281,8 @@ pub struct Prefs { pub recent_presets: Vec, /// Character panel: recently used font families, newest first. pub recent_fonts: Vec, + /// Preview panel: every shortcut's options. + pub preview: crate::preview::PreviewSettings, /// Top-level keys this version doesn't know (kept for newer versions). #[serde(flatten)] pub extra: Extra, @@ -312,6 +314,7 @@ impl Default for Prefs { recent_footage: vec![], recent_presets: vec![], recent_fonts: vec![], + preview: crate::preview::PreviewSettings::default(), extra: Extra::new(), } } @@ -446,6 +449,7 @@ impl Prefs { recent_footage: std::mem::take(&mut self.recent_footage), recent_presets: std::mem::take(&mut self.recent_presets), recent_fonts: std::mem::take(&mut self.recent_fonts), + preview: std::mem::take(&mut self.preview), ..d }; return Ok(()); diff --git a/crates/engine/src/preview.rs b/crates/engine/src/preview.rs new file mode 100644 index 00000000..5b9733dd --- /dev/null +++ b/crates/engine/src/preview.rs @@ -0,0 +1,550 @@ +//! Preview panel settings (After Effects' Preview panel): five keyboard shortcuts (Spacebar, +//! Shift+Spacebar, Numpad 0, Shift+Numpad 0, Alt+Numpad 0), each with its own saved options — +//! what to include (video, audio, overlays, layer controls), Loop, Cache Before Playback, Range, +//! Play From, Frame Rate, Skip, Resolution, Full Screen, "If caching, play cached frames" and +//! "Move time to preview time". +//! +//! The settings live in [`crate::prefs::Prefs::preview`] (saved with the other settings). +//! [`plan`] turns one shortcut's options, a comp and the current time into the frames to play; +//! the frontend's playback loop follows the plan. +//! +//! Commands: `playback.settings.get {shortcut?}`, `playback.settings.set {shortcut?, values?, +//! : value…, current?}`. + +use effectcraft_project::Comp; +use effectcraft_time::Tick; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, always, bad, str_p}; +use crate::{Result, Session, cmd, query}; + +/// A Preview panel shortcut. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreviewShortcut { + #[default] + Spacebar, + ShiftSpacebar, + Numpad0, + ShiftNumpad0, + AltNumpad0, +} + +impl PreviewShortcut { + pub const ALL: [PreviewShortcut; 5] = [Self::Spacebar, Self::ShiftSpacebar, Self::Numpad0, Self::ShiftNumpad0, Self::AltNumpad0]; + pub fn label(self) -> &'static str { + match self { + Self::Spacebar => "Spacebar", + Self::ShiftSpacebar => "Shift+Spacebar", + Self::Numpad0 => "Numpad 0", + Self::ShiftNumpad0 => "Shift+Numpad 0", + Self::AltNumpad0 => "Alt+Numpad 0", + } + } + pub fn id(self) -> &'static str { + match self { + Self::Spacebar => "spacebar", + Self::ShiftSpacebar => "shiftSpacebar", + Self::Numpad0 => "numpad0", + Self::ShiftNumpad0 => "shiftNumpad0", + Self::AltNumpad0 => "altNumpad0", + } + } + /// From an id (`shiftSpacebar`) or a label (`Shift+Spacebar`). + pub fn parse(s: &str) -> Option { + let k = s.to_ascii_lowercase().replace([' ', '+', '-', '_'], ""); + Self::ALL.into_iter().find(|x| x.id().to_ascii_lowercase() == k || x.label().to_ascii_lowercase().replace([' ', '+'], "") == k) + } +} + +/// Range of a preview. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreviewRange { + WorkArea, + #[default] + WorkAreaExtended, + EntireDuration, + /// The pre-roll before and post-roll after the current time. + AroundCurrentTime, +} + +impl PreviewRange { + pub const ALL: [PreviewRange; 4] = [Self::WorkArea, Self::WorkAreaExtended, Self::EntireDuration, Self::AroundCurrentTime]; + pub fn label(self) -> &'static str { + match self { + Self::WorkArea => "Work Area", + Self::WorkAreaExtended => "Work Area Extended By Current Time", + Self::EntireDuration => "Entire Duration", + Self::AroundCurrentTime => "Play Around Current Time", + } + } +} + +/// Where playback starts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PlayFrom { + RangeStart, + #[default] + CurrentTime, +} + +impl PlayFrom { + pub const ALL: [PlayFrom; 2] = [Self::RangeStart, Self::CurrentTime]; + pub fn label(self) -> &'static str { + match self { + Self::RangeStart => "Range Start", + Self::CurrentTime => "Current Time", + } + } +} + +/// Resolution of a preview (Auto: the viewer's own). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreviewResolution { + #[default] + Auto, + Full, + Half, + Third, + Quarter, + /// Every n-th pixel ([`PreviewPreset::custom_resolution`]). + Custom, +} + +impl PreviewResolution { + pub const ALL: [PreviewResolution; 6] = [Self::Auto, Self::Full, Self::Half, Self::Third, Self::Quarter, Self::Custom]; + pub fn label(self) -> &'static str { + match self { + Self::Auto => "Auto", + Self::Full => "Full", + Self::Half => "Half", + Self::Third => "Third", + Self::Quarter => "Quarter", + Self::Custom => "Custom", + } + } + /// Pixel step (1 Full … 4 Quarter, `custom` for Custom); `None` for Auto. + pub fn factor(self, custom: u32) -> Option { + match self { + Self::Auto => None, + Self::Full => Some(1), + Self::Half => Some(2), + Self::Third => Some(3), + Self::Quarter => Some(4), + Self::Custom => Some(custom.clamp(1, 40)), + } + } +} + +/// The options of one shortcut. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PreviewPreset { + pub include_video: bool, + pub include_audio: bool, + /// Grids, guides and safe margins stay visible while playing. + pub include_overlays: bool, + /// Layer handles, paths and motion paths stay visible while playing. + pub include_layer_controls: bool, + #[serde(rename = "loop")] + pub loop_: bool, + /// Render the whole range before playing it. + pub cache_before_playback: bool, + pub range: PreviewRange, + /// Play Around Current Time: seconds before and after the current time. + pub pre_roll: f64, + pub post_roll: f64, + pub play_from: PlayFrom, + /// Frames per second; `None` = Auto (the comp's rate). + pub frame_rate: Option, + /// Frames skipped between the frames played (0 = every frame). + pub skip: u32, + pub resolution: PreviewResolution, + /// Custom resolution: render every n-th pixel (1–40). + pub custom_resolution: u32, + /// Play in a full-screen viewer. + pub full_screen: bool, + /// With Cache Before Playback, pressing the shortcut again while caching plays the frames + /// already cached. + pub play_cached_frames: bool, + /// When the preview stops, the current time moves to the last frame shown (off: it returns + /// to where the preview started). + pub move_time_to_preview_time: bool, +} + +impl Default for PreviewPreset { + fn default() -> Self { + PreviewPreset { + include_video: true, + include_audio: true, + include_overlays: true, + include_layer_controls: false, + loop_: true, + cache_before_playback: false, + range: PreviewRange::WorkAreaExtended, + pre_roll: 2.0, + post_roll: 2.0, + play_from: PlayFrom::CurrentTime, + frame_rate: None, + skip: 0, + resolution: PreviewResolution::Auto, + custom_resolution: 2, + full_screen: false, + play_cached_frames: true, + move_time_to_preview_time: true, + } + } +} + +impl PreviewPreset { + /// The factory options of a shortcut. + pub fn default_for(s: PreviewShortcut) -> Self { + let d = PreviewPreset::default(); + match s { + PreviewShortcut::Spacebar => d, + // A lighter preview: every other frame. + PreviewShortcut::ShiftSpacebar => PreviewPreset { skip: 1, ..d }, + PreviewShortcut::Numpad0 => PreviewPreset { range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, ..d }, + PreviewShortcut::ShiftNumpad0 => { + PreviewPreset { range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, skip: 1, resolution: PreviewResolution::Half, ..d } + } + // Audio only. + PreviewShortcut::AltNumpad0 => PreviewPreset { include_video: false, range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, ..d }, + } + } + + fn normalize(&mut self) { + self.pre_roll = if self.pre_roll.is_finite() { self.pre_roll.clamp(0.0, 3600.0) } else { 2.0 }; + self.post_roll = if self.post_roll.is_finite() { self.post_roll.clamp(0.0, 3600.0) } else { 2.0 }; + self.frame_rate = self.frame_rate.filter(|r| r.is_finite() && *r > 0.0).map(|r| r.clamp(0.01, 999.0)); + self.skip = self.skip.min(99); + self.custom_resolution = self.custom_resolution.clamp(1, 40); + if !self.include_video && !self.include_audio { + self.include_video = true; + } + } +} + +/// The Preview panel: the shortcut shown in the panel and every shortcut's options. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PreviewSettings { + /// The shortcut whose options the panel shows (and the play button uses). + pub current: PreviewShortcut, + pub spacebar: PreviewPreset, + pub shift_spacebar: PreviewPreset, + pub numpad0: PreviewPreset, + pub shift_numpad0: PreviewPreset, + pub alt_numpad0: PreviewPreset, +} + +impl Default for PreviewSettings { + fn default() -> Self { + use PreviewShortcut as S; + PreviewSettings { + current: S::Spacebar, + spacebar: PreviewPreset::default_for(S::Spacebar), + shift_spacebar: PreviewPreset::default_for(S::ShiftSpacebar), + numpad0: PreviewPreset::default_for(S::Numpad0), + shift_numpad0: PreviewPreset::default_for(S::ShiftNumpad0), + alt_numpad0: PreviewPreset::default_for(S::AltNumpad0), + } + } +} + +impl PreviewSettings { + pub fn get(&self, s: PreviewShortcut) -> &PreviewPreset { + match s { + PreviewShortcut::Spacebar => &self.spacebar, + PreviewShortcut::ShiftSpacebar => &self.shift_spacebar, + PreviewShortcut::Numpad0 => &self.numpad0, + PreviewShortcut::ShiftNumpad0 => &self.shift_numpad0, + PreviewShortcut::AltNumpad0 => &self.alt_numpad0, + } + } + pub fn get_mut(&mut self, s: PreviewShortcut) -> &mut PreviewPreset { + match s { + PreviewShortcut::Spacebar => &mut self.spacebar, + PreviewShortcut::ShiftSpacebar => &mut self.shift_spacebar, + PreviewShortcut::Numpad0 => &mut self.numpad0, + PreviewShortcut::ShiftNumpad0 => &mut self.shift_numpad0, + PreviewShortcut::AltNumpad0 => &mut self.alt_numpad0, + } + } + /// The options of the shortcut shown in the panel. + pub fn active(&self) -> &PreviewPreset { + self.get(self.current) + } +} + +/// What a preview plays: frames `start..=end` (comp frames), starting at `first`, every +/// `step`-th frame, `fps` frames shown per second. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PreviewPlan { + pub start: i64, + pub end: i64, + pub first: i64, + pub step: i64, + /// Comp frames advanced per second of playback (the frame rate; Skip doesn't change speed). + pub fps: f64, + pub looping: bool, + pub cache_first: bool, + pub video: bool, + pub audio: bool, + /// Where the current time goes when the preview stops without moving it. + pub origin: Tick, +} + +impl PreviewPlan { + /// The frame shown `elapsed` seconds after the start; `None` once a non-looping preview has + /// played to its end. + pub fn frame_at(&self, elapsed: f64) -> Option { + let n = ((elapsed.max(0.0) * self.fps) / self.step as f64).floor() as i64; + self.frame_after(self.first, n) + } + + /// The frame `n` played frames after `from` (wrapping when looping). + pub fn frame_after(&self, from: i64, n: i64) -> Option { + let f = from + n * self.step; + if f <= self.end { + return Some(f); + } + if !self.looping { + return None; + } + // Loop back to the range start on the skip grid. + let len = ((self.end - self.start) / self.step + 1).max(1); + let past = (f - self.end - 1) / self.step; + Some(self.start + past.rem_euclid(len) * self.step) + } + + /// Every frame the preview shows, in range order. + pub fn frames(&self) -> impl Iterator + '_ { + (self.start..=self.end).step_by(self.step.max(1) as usize) + } +} + +/// The plan of a preview of `comp` with options `p`, started at comp time `now`. +pub fn plan(p: &PreviewPreset, comp: &Comp, now: Tick) -> PreviewPlan { + let fr = comp.frame_rate; + let last = fr.frame_at(comp.duration - comp.frame_duration()).max(0); + let cur = fr.frame_at(now).clamp(0, last); + let (wa, wb) = (fr.frame_at(comp.work_area.0), fr.frame_at(comp.work_area.1 - comp.frame_duration())); + let (wa, wb) = (wa.clamp(0, last), wb.clamp(0, last).max(wa.clamp(0, last))); + let (start, end) = match p.range { + PreviewRange::WorkArea => (wa, wb), + PreviewRange::WorkAreaExtended => (wa.min(cur), wb.max(cur)), + PreviewRange::EntireDuration => (0, last), + PreviewRange::AroundCurrentTime => { + let pre = (p.pre_roll * fr.as_f64()).round() as i64; + let post = (p.post_roll * fr.as_f64()).round() as i64; + ((cur - pre).max(0), (cur + post).min(last)) + } + }; + let step = p.skip as i64 + 1; + let first = match p.play_from { + PlayFrom::RangeStart => start, + // From the current time when it is inside the range (and not on its last frame). + PlayFrom::CurrentTime if cur >= start && cur < end => start + (cur - start) / step * step, + PlayFrom::CurrentTime => start, + }; + PreviewPlan { + start, + end, + first, + step, + fps: p.frame_rate.unwrap_or_else(|| fr.as_f64()), + looping: p.loop_, + cache_first: p.cache_before_playback, + video: p.include_video, + audio: p.include_audio, + origin: now, + } +} + +fn shortcut_p(s: &Session, p: &Value, cmd: &str) -> Result { + match str_p(p, "shortcut") { + Some(k) => { + PreviewShortcut::parse(k).ok_or_else(|| bad(cmd, format!("unknown shortcut `{k}` (spacebar|shiftSpacebar|numpad0|shiftNumpad0|altNumpad0)"))) + } + None => Ok(s.prefs.preview.current), + } +} + +fn get(s: &mut Session, p: &Value) -> Result { + let sc = shortcut_p(s, p, "playback.settings.get")?; + let mut out = serde_json::to_value(s.prefs.preview.get(sc)).map_err(|e| bad("playback.settings.get", e.to_string()))?; + out["shortcut"] = json!(sc.id()); + out["current"] = json!(s.prefs.preview.current.id()); + if let Some(c) = s.active_comp() { + let pl = plan(s.prefs.preview.get(sc), c, s.time()); + out["plan"] = json!({"start": pl.start, "end": pl.end, "first": pl.first, "step": pl.step, "fps": pl.fps}); + } + Ok(out) +} + +fn set(s: &mut Session, p: &Value) -> Result { + let c = "playback.settings.set"; + let sc = shortcut_p(s, p, c)?; + let mut fields = serde_json::Map::new(); + if let Some(Value::Object(m)) = p.get("values") { + fields.extend(m.clone()); + } + if let Value::Object(m) = p { + for (k, v) in m { + if !matches!(k.as_str(), "shortcut" | "values" | "current" | "reset") { + fields.insert(k.clone(), v.clone()); + } + } + } + let mut next = s.prefs.preview.clone(); + if p.get("reset").and_then(Value::as_bool) == Some(true) { + *next.get_mut(sc) = PreviewPreset::default_for(sc); + } + let mut cur = serde_json::to_value(next.get(sc)).map_err(|e| bad(c, e.to_string()))?; + for (k, v) in fields { + if cur.get(&k).is_none() { + return Err(bad(c, format!("unknown preview setting `{k}`"))); + } + // Frame rate: a number, or "auto"/null. + let v = if k == "frameRate" && (v.is_null() || v.as_str().is_some_and(|x| x.eq_ignore_ascii_case("auto"))) { Value::Null } else { v }; + cur[k.as_str()] = v; + } + let mut pr: PreviewPreset = serde_json::from_value(cur).map_err(|e| bad(c, e.to_string()))?; + pr.normalize(); + *next.get_mut(sc) = pr; + if let Some(k) = str_p(p, "current") { + next.current = PreviewShortcut::parse(k).ok_or_else(|| bad(c, format!("unknown shortcut `{k}`")))?; + } else if p.get("shortcut").is_some() && p.as_object().is_some_and(|m| m.len() == 1) { + // `{shortcut}` alone shows that shortcut in the panel. + next.current = sc; + } + s.prefs.preview = next; + s.save_prefs(); + get(s, &json!({"shortcut": sc.id()})) +} + +pub fn specs() -> Vec { + vec![ + query!( + "playback.settings.get", + "Preview Settings", + "{shortcut?: spacebar|shiftSpacebar|numpad0|shiftNumpad0|altNumpad0 (default: the one shown in the Preview panel)} → options + plan {start, end, first, step, fps}", + get + ), + cmd!( + "playback.settings.set", + "Change Preview Settings", + [], + None, + "{shortcut?, current?: shortcut shown in the panel, reset?: bool, values?: {…}, includeVideo?, includeAudio?, includeOverlays?, includeLayerControls?, loop?, cacheBeforePlayback?, range?: workArea|workAreaExtended|entireDuration|aroundCurrentTime, preRoll?, postRoll?: seconds, playFrom?: rangeStart|currentTime, frameRate?: fps|\"auto\", skip?, resolution?: auto|full|half|third|quarter|custom, customResolution?, fullScreen?, playCachedFrames?, moveTimeToPreviewTime?}", + always, + set + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Session; + + fn comp_session() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 320, "height": 180, "frameRate": 24, "duration": 10})).unwrap(); + s + } + + #[test] + fn ranges_and_play_from() { + let mut s = comp_session(); + let mut c = s.active_comp().unwrap().clone(); + c.work_area = (Tick::from_seconds_f64(2.0), Tick::from_seconds_f64(4.0)); + let at = |sec: f64| Tick::from_seconds_f64(sec); + let mut p = PreviewPreset { range: PreviewRange::WorkArea, play_from: PlayFrom::RangeStart, ..Default::default() }; + let pl = plan(&p, &c, at(1.0)); + assert_eq!((pl.start, pl.end, pl.first), (48, 95, 48)); + // Extended by the current time (before the work area). + p.range = PreviewRange::WorkAreaExtended; + let pl = plan(&p, &c, at(1.0)); + assert_eq!((pl.start, pl.end), (24, 95)); + // ... and after it. + let pl = plan(&p, &c, at(6.0)); + assert_eq!((pl.start, pl.end), (48, 144)); + p.range = PreviewRange::EntireDuration; + p.play_from = PlayFrom::CurrentTime; + let pl = plan(&p, &c, at(5.0)); + assert_eq!((pl.start, pl.end, pl.first), (0, 239, 120)); + p.range = PreviewRange::AroundCurrentTime; + p.pre_roll = 1.0; + p.post_roll = 0.5; + let pl = plan(&p, &c, at(5.0)); + assert_eq!((pl.start, pl.end, pl.first), (96, 132, 120)); + // Pre-roll clamps at the comp start. + let pl = plan(&p, &c, at(0.25)); + assert_eq!(pl.start, 0); + s.execute("playback.settings.set", json!({"range": "workArea"})).unwrap(); + } + + #[test] + fn skip_rate_and_loop() { + let s = comp_session(); + let c = s.active_comp().unwrap().clone(); + let p = PreviewPreset { range: PreviewRange::EntireDuration, play_from: PlayFrom::RangeStart, skip: 2, frame_rate: Some(12.0), ..Default::default() }; + let pl = plan(&p, &c, Tick::ZERO); + assert_eq!(pl.step, 3); + assert_eq!(pl.fps, 12.0); + // 12 comp frames per second, every third shown. + assert_eq!(pl.frame_at(0.0), Some(0)); + assert_eq!(pl.frame_at(0.26), Some(3)); + assert_eq!(pl.frame_at(1.0), Some(12)); + assert!(pl.frames().all(|f| f % 3 == 0)); + // Loop wraps onto the skip grid; no loop ends. + assert_eq!(pl.frame_after(237, 1), Some(0)); + let pl = PreviewPlan { looping: false, ..pl }; + assert_eq!(pl.frame_after(237, 1), None); + } + + #[test] + fn settings_commands_round_trip() { + let mut s = comp_session(); + let r = s.execute("playback.settings.get", json!({})).unwrap(); + assert_eq!(r["shortcut"], "spacebar"); + assert_eq!(r["range"], "workAreaExtended"); + // Each shortcut keeps its own options. + let r = s + .execute("playback.settings.set", json!({"shortcut": "shiftNumpad0", "skip": 3, "frameRate": 15, "resolution": "quarter", "loop": false})) + .unwrap(); + assert_eq!(r["skip"], 3); + assert_eq!(r["frameRate"], 15.0); + assert_eq!(r["plan"]["step"], 4); + let sp = s.execute("playback.settings.get", json!({"shortcut": "Spacebar"})).unwrap(); + assert_eq!(sp["skip"], 0); + assert_eq!(sp["frameRate"], Value::Null); + // Auto rate, panel shortcut, reset. + s.execute("playback.settings.set", json!({"shortcut": "shiftNumpad0", "frameRate": "auto", "current": "shiftNumpad0"})).unwrap(); + assert_eq!(s.prefs.preview.current, PreviewShortcut::ShiftNumpad0); + assert_eq!(s.prefs.preview.shift_numpad0.frame_rate, None); + assert!(s.execute("playback.settings.set", json!({"bogus": 1})).is_err()); + assert!(s.execute("playback.settings.set", json!({"shortcut": "f13"})).is_err()); + s.execute("playback.settings.set", json!({"shortcut": "shiftNumpad0", "reset": true})).unwrap(); + assert_eq!(s.prefs.preview.shift_numpad0, PreviewPreset::default_for(PreviewShortcut::ShiftNumpad0)); + // Persisted with the settings. + let v: Value = serde_json::from_str(&s.prefs.to_json()).unwrap(); + assert_eq!(v["preview"]["current"], "shiftNumpad0"); + let back: crate::prefs::Prefs = serde_json::from_value(v).unwrap(); + assert_eq!(back.preview, s.prefs.preview); + } + + #[test] + fn shortcut_names() { + for s in PreviewShortcut::ALL { + assert_eq!(PreviewShortcut::parse(s.id()), Some(s)); + assert_eq!(PreviewShortcut::parse(s.label()), Some(s)); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 48587f9e..92eed7ae 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -110,6 +110,20 @@ pub struct Playback { pub shown: u64, pub waiting: bool, pub fps: f64, + /// The Preview panel shortcut that started this preview and what it plays. + pub shortcut: effectcraft_engine::preview::PreviewShortcut, + pub plan: Option, + /// Cache Before Playback: rendering the range before the clock starts. + pub caching: bool, + /// Resolution override while playing (pixel step), `None` = the viewer's. + pub res: Option, + /// Include Overlays / Include Layer Controls. + pub overlays: bool, + pub layer_controls: bool, + /// Full Screen: the maximized panel to restore when the preview stops. + pub restore_max: Option>, + /// Audio-only previews: the frame under the audible sample (the current time stays put). + pub audio_frame: Option, } pub struct EffectcraftApp { @@ -419,6 +433,19 @@ impl EffectcraftApp { /// The render scale used by the viewer right now. pub fn viewer_scale(&self, zoom: f32, ppp: f32) -> f64 { + // The preview's Resolution (when not Auto) while it plays. + if self.playback.playing + && let Some(f) = self.playback.res + { + let r = match f { + 1 => state::Resolution::Full, + 2 => state::Resolution::Half, + 3 => state::Resolution::Third, + 4 => state::Resolution::Quarter, + n => state::Resolution::Custom(n.min(40) as u8), + }; + return r.scale(zoom, ppp); + } if self.playback.playing && self.ui.viewer.res == state::Resolution::Auto { // Keep previews snappy: half the displayed size, but no lower than Settings ▸ // Previews ▸ Adaptive Resolution Limit. @@ -510,64 +537,163 @@ impl EffectcraftApp { // ---------------------------------------------------------------- playback pub fn play(&mut self, now: f64) { + let sc = self.session.prefs.preview.current; + self.play_with(now, sc); + } + + /// Start the preview of a Preview panel shortcut (its range, play-from, rate, skip, + /// resolution, loop, caching, full screen and include options). + pub fn play_with(&mut self, now: f64, sc: effectcraft_engine::preview::PreviewShortcut) { let Some(c) = self.session.active_comp().cloned() else { return }; - let mut t = self.session.time(); - let (wa, wb) = c.work_area; - if t >= wb - c.frame_duration() || t < wa { - t = wa; + let preset = self.session.prefs.preview.get(sc).clone(); + let plan = effectcraft_engine::preview::plan(&preset, &c, self.session.time()); + let fr = c.frame_rate; + if plan.video { + self.session.set_time(fr.tick_of(plan.first)); + } + let restore_max = preset.full_screen.then_some(self.ui.maximized); + if preset.full_screen && self.ui.dock.contains(PanelKind::Composition) { + self.ui.maximized = Some(PanelKind::Composition); } - self.session.set_time(t); self.playback = Playback { playing: true, start_wall: now, - start_time: t, - start_frame: c.frame_rate.frame_at(t), + start_time: fr.tick_of(plan.first), + start_frame: plan.first, shown: 0, waiting: false, - fps: c.frame_rate.as_f64(), + fps: plan.fps, + shortcut: sc, + plan: Some(plan), + caching: plan.cache_first && plan.video, + res: preset.resolution.factor(preset.custom_resolution), + overlays: preset.include_overlays, + layer_controls: preset.include_layer_controls, + restore_max, + audio_frame: (!plan.video).then_some(plan.first), }; - self.start_audio(t); + if !self.playback.caching { + self.start_audio(fr.tick_of(plan.first)); + } } - /// Start audio preview from comp time `t` when the Preview panel includes audio, the comp - /// has something audible and an output device opens. + /// Start audio preview from comp time `t` when the preview includes audio at the comp's + /// frame rate, the comp has something audible and an output device opens. fn start_audio(&mut self, t: Tick) { self.audio = None; self.meter.clipped = [false; 2]; - if !self.ui.preview_audio { + let Some(pl) = self.playback.plan else { return }; + if !pl.audio { return; } let Some(cid) = self.session.active_comp_id() else { return }; let Some(c) = self.session.project.comp(cid).cloned() else { return }; + // A preview at another frame rate plays slower or faster than real time: silent. + if (pl.fps - c.frame_rate.as_f64()).abs() > 0.01 { + return; + } if !effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) { return; } let out = audio::AudioOutput::from_prefs(&self.session.prefs); let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return }; let mix = self.session.prefs.audio.preview_sample_rate; - match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop, mix) { + let fr = c.frame_rate; + match audio::AudioPlayback::start(dev, self.render_source(), cid, t, fr.tick_of(pl.start), fr.tick_of(pl.end + 1), pl.looping, mix) { Ok(a) => self.audio = Some(a), Err(e) => log::warn!("audio preview: {e}"), } } pub fn stop(&mut self) { - self.playback.playing = false; + let was = std::mem::replace(&mut self.playback.playing, false); self.audio = None; + self.playback.caching = false; + if !was { + return; + } + if let Some(m) = self.playback.restore_max.take() { + self.ui.maximized = m; + } + let Some(pl) = self.playback.plan else { return }; + let move_time = self.session.prefs.preview.get(self.playback.shortcut).move_time_to_preview_time; + if !move_time { + self.session.set_time(pl.origin); + } else if let Some(f) = self.playback.audio_frame + && let Some(c) = self.session.active_comp() + { + let t = c.frame_rate.tick_of(f); + self.session.set_time(t); + } } pub fn toggle_play(&mut self, now: f64) { - if self.playback.playing { self.stop() } else { self.play(now) } + let sc = self.session.prefs.preview.current; + self.toggle_play_with(now, sc); } - /// RAM preview: advance at the comp frame rate when frames are cached; otherwise hold and - /// keep rendering ahead (the green cache bar fills, then playback runs in real time). + /// A preview shortcut pressed: start its preview, or stop the running one. While Cache + /// Before Playback is still rendering, "If caching, play cached frames" plays what is + /// cached so far instead of stopping. + pub fn toggle_play_with(&mut self, now: f64, sc: effectcraft_engine::preview::PreviewShortcut) { + if !self.playback.playing { + self.play_with(now, sc); + return; + } + if self.playback.caching && self.session.prefs.preview.get(self.playback.shortcut).play_cached_frames && self.play_cached(now) { + return; + } + self.stop(); + } + + /// Cut the caching preview down to the frames cached from its first frame on and play them. + fn play_cached(&mut self, now: f64) -> bool { + let (Some(mut pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false }; + let scale = self.viewer_shown.as_ref().map(|(_, k)| k.scale as f64 / 1000.0).unwrap_or(1.0); + let mut last = None; + let mut f = pl.first; + while f <= pl.end && self.frames.is_cached(&self.frame_key(cid, f, scale)) { + last = Some(f); + f += pl.step; + } + let Some(last) = last else { return false }; + pl.end = last; + pl.start = pl.first; + self.playback.plan = Some(pl); + self.playback.caching = false; + self.playback.start_wall = now; + self.playback.start_frame = pl.first; + let t = self.session.active_comp().map(|c| c.frame_rate.tick_of(pl.first)).unwrap_or_default(); + self.start_audio(t); + true + } + + /// The viewer shows grids, guides and safe margins (hidden while a preview without + /// Include Overlays plays). + pub fn overlays_visible(&self) -> bool { + !self.playback.playing || self.playback.overlays + } + + /// The viewer shows layer handles and paths (View ▸ Show Layer Controls, and Include Layer + /// Controls while a preview plays). + pub fn layer_controls_visible(&self) -> bool { + self.ui.viewer.show_layer_controls && (!self.playback.playing || self.playback.layer_controls) + } + + /// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold + /// and keep rendering ahead (the green cache bar fills, then playback runs in real time). fn advance_playback(&mut self, ctx: &egui::Context, scale: f64) { let Some(cid) = self.session.active_comp_id() else { return }; let Some(c) = self.session.project.comp(cid).cloned() else { return }; let now = ctx.input(|i| i.time); let fr = c.frame_rate; - let (wa, wb) = (fr.frame_at(c.work_area.0), fr.frame_at(c.work_area.1 - c.frame_duration())); + let plan = self.playback.plan.filter(|_| self.playback.playing); + let (wa, wb) = match plan { + Some(p) => (p.start, p.end), + None => (fr.frame_at(c.work_area.0), fr.frame_at(c.work_area.1 - c.frame_duration())), + }; + let looping = plan.is_none_or(|p| p.looping); + let video = plan.is_none_or(|p| p.video); // Prefetch ahead. let cur = fr.frame_at(self.session.time()); // While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few @@ -580,17 +706,53 @@ impl EffectcraftApp { (self.frames_parallelism() / 2).max(2) as i64 }; let mut queued = self.frames.inflight(); + if self.playback.caching + && let Some(p) = plan + { + // Cache Before Playback: the whole range in play order, then start the clock. + let mut all = true; + let n = (p.end - p.start) / p.step + 1; + let order: Vec = (0..n).filter_map(|k| p.frame_after(p.first, k)).collect(); + for f in order { + let key = self.frame_key(cid, f, scale); + if self.frames.is_cached(&key) { + continue; + } + all = false; + if queued < ahead as usize { + self.request_frame(cid, f, scale); + queued += 1; + } + } + self.playback.waiting = !all; + if all { + self.playback.caching = false; + self.playback.start_wall = now; + self.playback.start_frame = p.first; + self.session.set_time(fr.tick_of(p.first)); + self.start_audio(fr.tick_of(p.first)); + } + ctx.request_repaint(); + return; + } + let step = plan.map_or(1, |p| p.step); for k in 0..ahead * 3 { - if queued >= ahead as usize { + if queued >= ahead as usize || !video { break; } - let mut f = cur + k; - if f > wb { - if !self.ui.preview_loop { - break; + let f = match plan { + Some(p) => match p.frame_after(cur, k) { + Some(f) => f, + None => break, + }, + None => { + let mut f = cur + k; + if f > wb { + f = wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1)); + } + f } - f = wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1)); - } + }; let key = self.frame_key(cid, f, scale); if !self.frames.is_cached(&key) { self.request_frame(cid, f, scale); @@ -607,33 +769,61 @@ impl EffectcraftApp { if !self.playback.playing { return; } + let snap = |f: i64| wa + (f - wa).div_euclid(step) * step; // Audio clock drives playback: show the frame under the audible sample (frames that // are not rendered yet are dropped, never delayed). if let Some(a) = &self.audio { if a.finished() { - self.session.set_time(fr.tick_of(wb)); + if video { + self.session.set_time(fr.tick_of(wb)); + } else { + self.playback.audio_frame = Some(wb); + } self.stop(); return; } - let target = audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb); - self.playback.waiting = !self.frames.is_cached(&self.frame_key(cid, target, scale)); - if target != cur { - self.session.set_time(fr.tick_of(target)); - self.playback.shown += 1; + let target = snap(audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb)); + if !video { + self.playback.audio_frame = Some(target); + } else { + self.playback.waiting = !self.frames.is_cached(&self.frame_key(cid, target, scale)); + if target != cur { + self.session.set_time(fr.tick_of(target)); + self.playback.shown += 1; + } } ctx.request_repaint(); return; } let elapsed = now - self.playback.start_wall; - let mut target = self.playback.start_frame + (elapsed * fr.as_f64()).floor() as i64; - if target > wb { - if self.ui.preview_loop { - target = wa + (target - wa).rem_euclid((wb - wa + 1).max(1)); - } else { - self.session.set_time(fr.tick_of(wb)); - self.stop(); - return; + let n = ((elapsed * self.playback.fps) / step as f64).floor() as i64; + let target = match plan { + Some(p) => p.frame_after(self.playback.start_frame, n), + None => { + let t = self.playback.start_frame + n; + if t <= wb { + Some(t) + } else if looping { + Some(wa + (t - wa).rem_euclid((wb - wa + 1).max(1))) + } else { + None + } } + }; + let Some(target) = target else { + if video { + self.session.set_time(fr.tick_of(wb)); + } else { + self.playback.audio_frame = Some(wb); + } + self.stop(); + return; + }; + if !video { + // Audio-only preview without an audio device: the clock runs, the time stays. + self.playback.audio_frame = Some(target); + ctx.request_repaint(); + return; } let key = self.frame_key(cid, target, scale); if self.frames.is_cached(&key) { @@ -645,8 +835,7 @@ impl EffectcraftApp { } else { // Not cached yet: hold the clock at the current frame (cache first, then play). self.playback.waiting = true; - let next = cur + 1; - let next = if next > wb { wa } else { next }; + let next = plan.and_then(|p| p.frame_after(cur, 1)).unwrap_or(if cur + 1 > wb { wa } else { cur + 1 }); if self.frames.is_cached(&self.frame_key(cid, next, scale)) { self.session.set_time(fr.tick_of(next)); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f3800ef6..f3c8df3c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -27,7 +27,10 @@ macro_rules! uic { /// UI-only commands (not in the AE menus; shortcuts and the command palette). pub const UI_COMMANDS: &[UiCommand] = &[ - uic!("playback.ramPreview", "Play Current Preview", [], Some("Num0")), + uic!("playback.ramPreview", "Preview (Numpad 0)", [], Some("Num0")), + uic!("playback.preview.shiftSpacebar", "Preview (Shift+Spacebar)", [], Some("Shift+Space")), + uic!("playback.preview.shiftNumpad0", "Preview (Shift+Numpad 0)", [], Some("Shift+Num0")), + uic!("playback.preview.altNumpad0", "Preview (Alt+Numpad 0)", [], Some("Alt+Num0")), uic!("playback.stop", "Stop", [], None), uic!("view.fit", "Fit", [], Some("Shift+/")), uic!("view.actualSize", "100%", [], Some("/")), @@ -290,7 +293,12 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V let timing = |app: &mut EffectcraftApp, op: &str| app.session.execute("layer.timing", json!({"op": op})).map_err(|e| e.to_string()); match id { "playback.ramPreview" => { - app.toggle_play(now); + app.toggle_play_with(now, effectcraft_engine::preview::PreviewShortcut::Numpad0); + return Ok(json!({"playing": app.playback.playing})); + } + "playback.preview.shiftSpacebar" | "playback.preview.shiftNumpad0" | "playback.preview.altNumpad0" => { + let sc = effectcraft_engine::preview::PreviewShortcut::parse(id.trim_start_matches("playback.preview.")).ok_or("unknown preview shortcut")?; + app.toggle_play_with(now, sc); return Ok(json!({"playing": app.playback.playing})); } "playback.stop" => { @@ -534,8 +542,14 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu Value::Null } "playback.toggle" => { - app.toggle_play(now); - json!({"playing": app.playback.playing}) + // The play button and Spacebar: `{shortcut?}` picks which Preview panel shortcut's + // options to play with (default Spacebar). + let sc = match p.get("shortcut").and_then(Value::as_str) { + Some(k) => effectcraft_engine::preview::PreviewShortcut::parse(k).ok_or_else(|| format!("unknown preview shortcut `{k}`"))?, + None => effectcraft_engine::preview::PreviewShortcut::Spacebar, + }; + app.toggle_play_with(now, sc); + json!({"playing": app.playback.playing, "shortcut": sc.id()}) } "playback.cacheWhenIdle" => { let r = toggle(&mut app.ui.cache_when_idle, &p); @@ -543,8 +557,11 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu r } "playback.audio" => { - let r = toggle(&mut app.ui.preview_audio, &p); - if !app.ui.preview_audio { + // Include Audio of the shortcut shown in the Preview panel. + let mut on = app.session.prefs.preview.active().include_audio; + let r = toggle(&mut on, &p); + app.session.execute("playback.settings.set", json!({"includeAudio": on})).map_err(|e| e.to_string())?; + if !on { app.audio = None; } r @@ -955,7 +972,7 @@ pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option "view.snapToGrid" => Some(v.snap_grid), "view.layerControls" => Some(v.show_layer_controls), "playback.cacheWhenIdle" => Some(app.ui.cache_when_idle), - "playback.audio" => Some(app.ui.preview_audio), + "playback.audio" => Some(app.session.prefs.preview.active().include_audio), "view.res.full" | "view.res.half" | "view.res.third" | "view.res.quarter" => { Some(v.res.label().eq_ignore_ascii_case(e.command.trim_start_matches("view.res."))) } diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 42c3aacf..3d2b6180 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -294,7 +294,7 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { let v = &app.ui.viewer; (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h); v.res.label().hash(&mut h); - (app.ui.cache_when_idle, app.ui.preview_audio, &app.ui.workspace, app.dialog.is_some()).hash(&mut h); + (app.ui.cache_when_idle, app.session.prefs.preview.active().include_audio, &app.ui.workspace, app.dialog.is_some()).hash(&mut h); h.finish() } diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index ea12591c..7b750be7 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -13,8 +13,11 @@ pub fn placeholder(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, p: P ui.painter().text(rect.center(), Align2::CENTER_CENTER, format!("{} — coming soon", p.title()), Tokens::ui(12.0), t.text_faint); } -/// Preview panel: transport controls and RAM preview options. +/// Preview panel: transport controls, the shortcut whose options are shown, and that +/// shortcut's options (each Preview shortcut keeps its own; see `effectcraft_engine::preview`). +/// Every change runs `playback.settings.set`. pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + use effectcraft_engine::preview::{PlayFrom, PreviewRange, PreviewResolution, PreviewShortcut}; let t = app.tokens; let ctx = ui.ctx().clone(); let p = ui.painter().with_clip_rect(rect); @@ -29,57 +32,248 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { (Icon::StepFwd, "time.nextFrame", "Next Frame"), (Icon::Last, "time.end", "Last Frame"), ]; + let current = app.session.prefs.preview.current; for (icon, cmd, tip) in buttons { let r = Rect::from_min_size(pos2(x, y), vec2(bw, 28.0)); let icon = if cmd == "playback.toggle" && app.playback.playing { Icon::Pause } else { icon }; if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::new(("pv", cmd))).on_hover_text(tip).clicked() { - let _ = crate::menus::invoke(app, &ctx, cmd, json!({})); + // The play button plays with the options of the shortcut shown below. + let params = if cmd == "playback.toggle" { json!({"shortcut": current.id()}) } else { json!({}) }; + let _ = crate::menus::invoke(app, &ctx, cmd, params); } app.auto.add(&format!("preview.{}", tip.replace([' ', '/'], "")), r, tip); x += bw + 4.0; } - let mut yy = y + 44.0; - let row = |p: &egui::Painter, yy: f32, k: &str, v: &str| { - p.text(pos2(rect.min.x + 12.0, yy), Align2::LEFT_CENTER, k, Tokens::ui(12.0), t.text_dim); - p.text(pos2(rect.min.x + 130.0, yy), Align2::LEFT_CENTER, v, Tokens::ui(12.0), t.text); + let o = app.session.prefs.preview.get(current).clone(); + let mut changes: Vec = vec![]; + let x0 = rect.min.x + 12.0; + let xv = rect.min.x + 92.0; + let wv = (rect.max.x - xv - 12.0).clamp(80.0, 240.0); + let label = |yy: f32, k: &str| { + p.text(pos2(x0, yy), Align2::LEFT_CENTER, k, Tokens::ui(12.0), t.text_dim); }; - row(&p, yy, "Shortcut", "Spacebar"); - yy += 22.0; - let lr = Rect::from_min_size(pos2(rect.min.x + 10.0, yy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, lr, app.ui.preview_loop, &t, egui::Id::new("pv-loop")).clicked() { - app.ui.preview_loop = !app.ui.preview_loop; + let mut yy = y + 46.0; + + // Shortcut. + label(yy, "Shortcut"); + let names: Vec = PreviewShortcut::ALL.iter().map(|s| s.label().to_string()).collect(); + if let Some(i) = dropdown_row( + app, + ui, + Rect::from_min_size(pos2(xv, yy - 10.0), vec2(wv, 20.0)), + "shortcut", + current.label(), + &names, + PreviewShortcut::ALL.iter().position(|s| *s == current), + ) { + let s = PreviewShortcut::ALL[i]; + changes.push(json!({"shortcut": s.id(), "current": s.id()})); } - app.auto.add("preview.loop", lr, "Loop"); - p.text(pos2(lr.max.x + 6.0, yy), Align2::LEFT_CENTER, "Loop", Tokens::ui(12.0), t.text); - let ar = Rect::from_min_size(pos2(rect.min.x + 90.0, yy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, ar, app.ui.preview_audio, &t, egui::Id::new("pv-audio")).clicked() { - let _ = crate::menus::invoke(app, &ctx, "playback.audio", json!({})); - } - app.auto.add("preview.audio", ar, "Include Audio"); - p.text(pos2(ar.max.x + 6.0, yy), Align2::LEFT_CENTER, "Include Audio", Tokens::ui(12.0), t.text); - yy += 24.0; - row(&p, yy, "Range", "Work Area Extended By Current Time"); - yy += 22.0; - row(&p, yy, "Play From", "Current Time"); - yy += 22.0; - let comp = app.session.active_comp().cloned(); - row(&p, yy, "Frame Rate", &comp.as_ref().map(|c| format!("({:.2})", c.frame_rate.as_f64())).unwrap_or_default()); - yy += 22.0; - row(&p, yy, "Resolution", app.ui.viewer.res.label()); yy += 26.0; - if let Some(c) = comp { + + // Include Video / Audio / Overlays / Layer Controls, Loop, Cache Before Playback. + let checks: [(&str, &str, bool); 6] = [ + ("includeVideo", "Video", o.include_video), + ("includeAudio", "Audio", o.include_audio), + ("includeOverlays", "Overlays", o.include_overlays), + ("includeLayerControls", "Layer Controls", o.include_layer_controls), + ("loop", "Loop", o.loop_), + ("cacheBeforePlayback", "Cache Before Playback", o.cache_before_playback), + ]; + for (i, (key, text, on)) in checks.into_iter().enumerate() { + let col = if i < 4 { i % 2 } else { 0 }; + let row = if i < 4 { i / 2 } else { i - 2 }; + let cx = x0 + col as f32 * 110.0 - 2.0; + let cy = yy + row as f32 * 22.0; + if check_row(app, ui, &p, pos2(cx, cy), key, text, on) { + changes.push(json!({"shortcut": current.id(), key: !on})); + } + } + yy += 4.0 * 22.0 + 6.0; + + // Range (and the pre/post-roll of Play Around Current Time). + label(yy, "Range"); + let ranges: Vec = PreviewRange::ALL.iter().map(|r| r.label().to_string()).collect(); + let range_ids = ["workArea", "workAreaExtended", "entireDuration", "aroundCurrentTime"]; + if let Some(i) = dropdown_row( + app, + ui, + Rect::from_min_size(pos2(xv, yy - 10.0), vec2(wv, 20.0)), + "range", + o.range.label(), + &ranges, + PreviewRange::ALL.iter().position(|r| *r == o.range), + ) { + changes.push(json!({"shortcut": current.id(), "range": range_ids[i]})); + } + yy += 24.0; + if o.range == PreviewRange::AroundCurrentTime { + for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() { + let lx = xv + k as f32 * 100.0; + p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim); + let (r, nv, _) = widgets::hot_number_at(ui, pos2(lx + 52.0, yy - 9.0), egui::Id::new(("pv-roll", key)), v, 0.05, (0.0, 600.0), 1, "s", &t); + app.auto.add(&format!("preview.{key}"), r, text); + if let Some(nv) = nv { + changes.push(json!({"shortcut": current.id(), key: nv})); + } + } + yy += 22.0; + } + + // Play From. + label(yy, "Play From"); + let froms: Vec = PlayFrom::ALL.iter().map(|f| f.label().to_string()).collect(); + if let Some(i) = dropdown_row( + app, + ui, + Rect::from_min_size(pos2(xv, yy - 10.0), vec2(wv, 20.0)), + "playFrom", + o.play_from.label(), + &froms, + PlayFrom::ALL.iter().position(|f| *f == o.play_from), + ) { + changes.push(json!({"shortcut": current.id(), "playFrom": (["rangeStart", "currentTime"][i])})); + } + yy += 26.0; + + // Frame Rate, Skip, Resolution. + let comp_fps = app.session.active_comp().map(|c| c.frame_rate.as_f64()); + label(yy, "Frame Rate"); + const RATES: [f64; 12] = [8.0, 10.0, 12.0, 12.5, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0]; + let auto = format!("({:.2}) Auto", comp_fps.unwrap_or(0.0)); + let mut rates = vec![auto.clone()]; + rates.extend(RATES.iter().map(|r| fmt_rate(*r))); + let rate_text = o.frame_rate.map(fmt_rate).unwrap_or(auto); + let rate_cur = match o.frame_rate { + None => Some(0), + Some(r) => RATES.iter().position(|x| (x - r).abs() < 1e-3).map(|i| i + 1), + }; + if let Some(i) = dropdown_row(app, ui, Rect::from_min_size(pos2(xv, yy - 10.0), vec2(96.0, 20.0)), "frameRate", &rate_text, &rates, rate_cur) { + let v = if i == 0 { json!("auto") } else { json!(RATES[i - 1]) }; + changes.push(json!({"shortcut": current.id(), "frameRate": v})); + } + let sx = xv + 106.0; + p.text(pos2(sx, yy), Align2::LEFT_CENTER, "Skip", Tokens::ui(12.0), t.text_dim); + let (r, nv, _) = widgets::hot_number_at(ui, pos2(sx + 30.0, yy - 9.0), egui::Id::new("pv-skip"), o.skip as f64, 0.1, (0.0, 99.0), 0, "", &t); + app.auto.add("preview.skip", r, "Skip"); + if let Some(nv) = nv { + changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32})); + } + yy += 24.0; + label(yy, "Resolution"); + let ress: Vec = PreviewResolution::ALL.iter().map(|r| r.label().to_string()).collect(); + let res_ids = ["auto", "full", "half", "third", "quarter", "custom"]; + if let Some(i) = dropdown_row( + app, + ui, + Rect::from_min_size(pos2(xv, yy - 10.0), vec2(96.0, 20.0)), + "resolution", + o.resolution.label(), + &ress, + PreviewResolution::ALL.iter().position(|r| *r == o.resolution), + ) { + changes.push(json!({"shortcut": current.id(), "resolution": res_ids[i]})); + } + if o.resolution == PreviewResolution::Custom { + let (r, nv, _) = + widgets::hot_number_at(ui, pos2(xv + 106.0, yy - 9.0), egui::Id::new("pv-custom-res"), o.custom_resolution as f64, 0.1, (1.0, 40.0), 0, "", &t); + app.auto.add("preview.customResolution", r, "Custom Resolution"); + if let Some(nv) = nv { + changes.push(json!({"shortcut": current.id(), "customResolution": nv.round() as u32})); + } + } + yy += 24.0; + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", "Full Screen", o.full_screen) { + changes.push(json!({"shortcut": current.id(), "fullScreen": !o.full_screen})); + } + yy += 24.0; + + // What stopping does. + p.text(pos2(x0, yy), Align2::LEFT_CENTER, format!("On ({}) stop:", current.label()), Tokens::ui(11.5), t.text_dim); + yy += 20.0; + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", "If caching, play cached frames", o.play_cached_frames) { + changes.push(json!({"shortcut": current.id(), "playCachedFrames": !o.play_cached_frames})); + } + yy += 22.0; + if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "moveTimeToPreviewTime", "Move time to preview time", o.move_time_to_preview_time) { + changes.push(json!({"shortcut": current.id(), "moveTimeToPreviewTime": !o.move_time_to_preview_time})); + } + yy += 24.0; + for c in changes { + if let Err(e) = app.session.execute("playback.settings.set", c) { + app.ui.status = e.to_string(); + } + } + + // Cache bar over the preview range. + if let Some(c) = app.session.active_comp().cloned() { let cid = app.session.active_comp_id().map(|i| i.0).unwrap_or(0); let scale = app.viewer_shown.as_ref().map(|(_, k)| k.scale).unwrap_or(1000); - let cached = app.frames.cached_frames(app.session.revision, cid, scale).len(); - let total = c.frame_rate.frame_at(c.work_area.1) - c.frame_rate.frame_at(c.work_area.0); + let pl = match app.playback.plan.filter(|_| app.playback.playing) { + Some(pl) => pl, + None => effectcraft_engine::preview::plan(&o, &c, app.session.time()), + }; + let cached_set = app.frames.cached_frames(app.session.revision, cid, scale); + let total = pl.frames().count(); + let cached = pl.frames().filter(|f| cached_set.binary_search(f).is_ok()).count(); let bar = Rect::from_min_size(pos2(rect.min.x + 12.0, yy), vec2(rect.width() - 24.0, 6.0)); p.rect_filled(bar, 3.0, t.field_bg); let f = (cached as f32 / total.max(1) as f32).min(1.0); p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * f, bar.height())), 3.0, t.cache_green); - p.text(pos2(bar.min.x, bar.max.y + 12.0), Align2::LEFT_CENTER, format!("{cached} / {total} frames cached"), Tokens::ui(11.0), t.text_faint); + let status = if app.playback.playing && app.playback.caching { " — caching before playback" } else { "" }; + p.text(pos2(bar.min.x, bar.max.y + 12.0), Align2::LEFT_CENTER, format!("{cached} / {total} frames cached{status}"), Tokens::ui(11.0), t.text_faint); } } +fn fmt_rate(r: f64) -> String { + if (r - r.round()).abs() < 1e-6 { format!("{r:.0}") } else { format!("{r}") } +} + +/// A checkbox with its label; returns true when clicked. +fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: egui::Pos2, key: &str, text: &str, on: bool) -> bool { + let t = app.tokens; + let r = Rect::from_min_size(pos2(at.x, at.y - 8.0), vec2(16.0, 16.0)); + let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("pv-check", key))).clicked(); + p.text(pos2(r.max.x + 5.0, at.y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text); + app.auto.add(&format!("preview.{key}"), r, text); + clicked +} + +/// A dropdown with its popup list (automation ids `preview.` and `preview..`); +/// returns the chosen index. +fn dropdown_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, key: &str, text: &str, items: &[String], current: Option) -> Option { + let t = app.tokens; + let pid = egui::Id::new(("pv-pop", key)); + if widgets::dropdown(ui, r, text, &t, pid.with("btn")).clicked() { + let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false)); + ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); + } + app.auto.add(&format!("preview.{key}"), r, text); + let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false)); + if !open { + return None; + } + let mut chosen = None; + let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(r.left_bottom() + vec2(0.0, 2.0)).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(r.width().max(140.0)); + for (i, label) in items.iter().enumerate() { + let resp = ui.selectable_label(current == Some(i), label.as_str()); + app.auto.add(&format!("preview.{key}.{i}"), resp.rect, label); + if resp.clicked() { + chosen = Some(i); + } + } + }); + }); + let outside = + ui.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer() && !r.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default()); + if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(pid.with("open"), false)); + } + chosen +} + /// Audio panel: L/R VU meters (dBFS, 0 to -48) with peak hold and clip indicators, fed by the /// audio preview; the selected layer's Audio Levels on the right. pub fn audio(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 81b9d077..4b79dc05 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -7,7 +7,6 @@ use effectcraft_engine::render::EvalCtx; use egui::{Align2, Rect, Sense, pos2, vec2}; use serde_json::json; -use crate::icons::{self, Icon}; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -568,90 +567,131 @@ fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui:: chosen } -/// Align panel: align selected layers to the composition (or to the selection). +/// Align panel: Align Layers to Selection / Composition, the six align buttons and the six +/// Distribute Layers buttons. Buttons run `layer.align {edge, to}` and `layer.distribute +/// {mode}` on the selected layers. pub fn align(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().with_clip_rect(rect); let x0 = rect.min.x + 10.0; - let y = rect.min.y + 10.0; - p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Align Layers to: Composition", Tokens::ui(11.5), t.text_dim); + let mut y = rect.min.y + 10.0; + p.text(pos2(x0, y + 10.0), Align2::LEFT_CENTER, "Align Layers to:", Tokens::ui(11.5), t.text_dim); + let dr = Rect::from_min_size(pos2(x0 + 100.0, y), vec2(110.0, 20.0)); + let to_sel = app.ui.align_to_selection; + let pid = egui::Id::new("align-to-pop"); + if widgets::dropdown(ui, dr, if to_sel { "Selection" } else { "Composition" }, &t, pid.with("btn")).clicked() { + widgets::open_popup(ui, pid); + } + app.auto.add("align.to", dr, if to_sel { "Selection" } else { "Composition" }); + if let Some(i) = + widgets::popup_menu(ui, pid, dr.left_bottom() + vec2(0.0, 2.0), &["Selection".to_string(), "Composition".to_string()], Some(if to_sel { 0 } else { 1 })) + { + app.ui.align_to_selection = i == 0; + } + y += 28.0; let ops = ["left", "hcenter", "right", "top", "vcenter", "bottom"]; - for (i, op) in ops.iter().enumerate() { - let r = Rect::from_min_size(pos2(x0 + i as f32 * 34.0 + if i >= 3 { 12.0 } else { 0.0 }, y + 24.0), vec2(30.0, 28.0)); - let resp = ui.interact(r, egui::Id::new(("align", *op)), Sense::click()); - p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); - // Glyph: a bar (alignment edge) and two boxes. - let c = r.center(); - let st = egui::Stroke::new(1.3, t.text); - match *op { - "left" | "hcenter" | "right" => { - let ex = match *op { - "left" => r.min.x + 7.0, - "right" => r.max.x - 7.0, - _ => c.x, - }; - p.line_segment([pos2(ex, r.min.y + 5.0), pos2(ex, r.max.y - 5.0)], st); - let off = |w: f32| match *op { - "left" => ex, - "right" => ex - w, - _ => ex - w / 2.0, - }; - p.rect_filled(Rect::from_min_size(pos2(off(14.0), c.y - 7.0), vec2(14.0, 5.0)), 1.0, t.text_dim); - p.rect_filled(Rect::from_min_size(pos2(off(9.0), c.y + 2.0), vec2(9.0, 5.0)), 1.0, t.text_dim); - } - _ => { - let ey = match *op { - "top" => r.min.y + 6.0, - "bottom" => r.max.y - 6.0, - _ => c.y, - }; - p.line_segment([pos2(r.min.x + 5.0, ey), pos2(r.max.x - 5.0, ey)], st); - let off = |h: f32| match *op { - "top" => ey, - "bottom" => ey - h, - _ => ey - h / 2.0, - }; - p.rect_filled(Rect::from_min_size(pos2(c.x - 8.0, off(14.0)), vec2(5.0, 14.0)), 1.0, t.text_dim); - p.rect_filled(Rect::from_min_size(pos2(c.x + 3.0, off(9.0)), vec2(5.0, 9.0)), 1.0, t.text_dim); + let mut run: Option<(&str, &str)> = None; + for (row, kind) in ["align", "distribute"].into_iter().enumerate() { + if row == 1 { + p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Distribute Layers:", Tokens::ui(11.5), t.text_dim); + y += 20.0; + } + for (i, op) in ops.iter().enumerate() { + let r = Rect::from_min_size(pos2(x0 + i as f32 * 34.0 + if i >= 3 { 12.0 } else { 0.0 }, y), vec2(30.0, 28.0)); + let resp = ui.interact(r, egui::Id::new((kind, *op)), Sense::click()); + p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); + align_glyph(&p, r, op, row == 1, &t); + let tip = align_tip(kind, op); + app.auto.add(&format!("{kind}.{op}"), r, &tip); + if resp.on_hover_text(tip).clicked() { + run = Some((kind, op)); } } - app.auto.add(&format!("align.{op}"), r, op); - if resp.clicked() { - align_layers(app, op); + y += 34.0; + } + if let Some((kind, op)) = run { + let r = if kind == "align" { + let to = if app.ui.align_to_selection { "selection" } else { "composition" }; + app.session.execute("layer.align", json!({"edge": op, "to": to})) + } else { + app.session.execute("layer.distribute", json!({"mode": op})) + }; + if let Err(e) = r { + app.ui.status = e.to_string(); } } - let _ = icons::paint; - let _ = Icon::Grid; } -/// Move selected layers so their content bounds align to the comp. -pub fn align_layers(app: &mut EffectcraftApp, op: &str) { - let Some(cid) = app.session.active_comp_id() else { return }; - let Some(comp) = app.session.project.comp(cid).cloned() else { return }; - let time = app.session.time(); - let mut moves = vec![]; - { - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref(), footage: None }; - for l in comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id)) { - let Some(b) = effectcraft_engine::render::content_bounds(&ectx, l) else { continue }; - let (m, _) = ectx.layer_to_comp(l); - let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| m.apply(effectcraft_engine::geom::vec2(p[0], p[1]))); - let (x0, x1) = (pts.iter().map(|p| p.x).fold(f64::INFINITY, f64::min), pts.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max)); - let (y0, y1) = (pts.iter().map(|p| p.y).fold(f64::INFINITY, f64::min), pts.iter().map(|p| p.y).fold(f64::NEG_INFINITY, f64::max)); - let (cw, ch) = (comp.width as f64, comp.height as f64); - let (dx, dy) = match op { - "left" => (-x0, 0.0), - "right" => (cw - x1, 0.0), - "hcenter" => (cw / 2.0 - (x0 + x1) / 2.0, 0.0), - "top" => (0.0, -y0), - "bottom" => (0.0, ch - y1), - _ => (0.0, ch / 2.0 - (y0 + y1) / 2.0), - }; - let pos = l.transform().map(|tr| ectx.v3(l, tr, "position", [0.0; 3])).unwrap_or([0.0; 3]); - moves.push((l.id.0, [pos[0] + dx, pos[1] + dy, pos[2]])); +fn align_tip(kind: &str, op: &str) -> String { + let what = match op { + "left" => "Left", + "hcenter" => "Horizontal Center", + "right" => "Right", + "top" => "Top", + "vcenter" => "Vertical Center", + _ => "Bottom", + }; + if kind == "align" { format!("Align {what}") } else { format!("Distribute {what}") } +} + +/// Button glyph: the alignment edge as a line and two boxes (distribute: three boxes spread, +/// the line through each). +fn align_glyph(p: &egui::Painter, r: Rect, op: &str, distribute: bool, t: &Tokens) { + let c = r.center(); + let st = egui::Stroke::new(1.3, t.text); + let horizontal = matches!(op, "left" | "hcenter" | "right"); + if distribute { + for k in 0..3 { + let f = (k as f32 - 1.0) * 7.0; + if horizontal { + let bx = c.x + f; + let ex = match op { + "left" => bx - 2.5, + "right" => bx + 2.5, + _ => bx, + }; + p.rect_filled(Rect::from_center_size(pos2(bx, c.y), vec2(5.0, 10.0 + k as f32 * 2.0)), 1.0, t.text_dim); + p.line_segment([pos2(ex, r.min.y + 5.0), pos2(ex, r.max.y - 5.0)], st); + } else { + let by = c.y + f; + let ey = match op { + "top" => by - 2.5, + "bottom" => by + 2.5, + _ => by, + }; + p.rect_filled(Rect::from_center_size(pos2(c.x, by), vec2(10.0 + k as f32 * 2.0, 5.0)), 1.0, t.text_dim); + p.line_segment([pos2(r.min.x + 5.0, ey), pos2(r.max.x - 5.0, ey)], st); + } } + return; } - for (id, v) in moves { - let _ = app.session.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": v})); + if horizontal { + let ex = match op { + "left" => r.min.x + 7.0, + "right" => r.max.x - 7.0, + _ => c.x, + }; + p.line_segment([pos2(ex, r.min.y + 5.0), pos2(ex, r.max.y - 5.0)], st); + let off = |w: f32| match op { + "left" => ex, + "right" => ex - w, + _ => ex - w / 2.0, + }; + p.rect_filled(Rect::from_min_size(pos2(off(14.0), c.y - 7.0), vec2(14.0, 5.0)), 1.0, t.text_dim); + p.rect_filled(Rect::from_min_size(pos2(off(9.0), c.y + 2.0), vec2(9.0, 5.0)), 1.0, t.text_dim); + } else { + let ey = match op { + "top" => r.min.y + 6.0, + "bottom" => r.max.y - 6.0, + _ => c.y, + }; + p.line_segment([pos2(r.min.x + 5.0, ey), pos2(r.max.x - 5.0, ey)], st); + let off = |h: f32| match op { + "top" => ey, + "bottom" => ey - h, + _ => ey - h / 2.0, + }; + p.rect_filled(Rect::from_min_size(pos2(c.x - 8.0, off(14.0)), vec2(5.0, 14.0)), 1.0, t.text_dim); + p.rect_filled(Rect::from_min_size(pos2(c.x + 3.0, off(9.0)), vec2(5.0, 9.0)), 1.0, t.text_dim); } } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 66c3c94a..cb897125 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -519,7 +519,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("viewer.area", area, "Composition viewer"); // Guides. - if app.ui.viewer.safe_margins { + let overlays = app.overlays_visible(); + if overlays && app.ui.viewer.safe_margins { let g = &app.session.prefs.grids; let (act, title) = (1.0 - g.action_safe as f32 / 100.0, 1.0 - g.title_safe as f32 / 100.0); for (k, a) in [(act, 120u8), (title, 160)] { @@ -530,7 +531,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { painter.line_segment([c - vec2(10.0, 0.0), c + vec2(10.0, 0.0)], Stroke::new(1.0, Color32::from_white_alpha(140))); painter.line_segment([c - vec2(0.0, 10.0), c + vec2(0.0, 10.0)], Stroke::new(1.0, Color32::from_white_alpha(140))); } - if app.ui.viewer.grid { + if overlays && app.ui.viewer.grid { // Settings ▸ Grids & Guides: gridline spacing, subdivisions and colour. let g = &app.session.prefs.grids; let [r, gg, b] = hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]); @@ -567,7 +568,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { vt::proportional_grid(app, &painter, comp_rect); // Comp guides (View ▸ Show Guides) and the region of interest. - if app.ui.viewer.guides { + if overlays && app.ui.viewer.guides { let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]); let stroke = Stroke::new(1.0, Color32::from_rgb(r, gg, b)); // Settings ▸ Grids & Guides ▸ Guide Style. @@ -625,7 +626,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if finish || app.ui.tool != Tool::Selection { ov::end_free_transform(&ctx); } - if app.ui.viewer.show_layer_controls { + if app.layer_controls_visible() { // Settings ▸ General ▸ Path Point and Handle Size; Appearance ▸ Use Label Color for // Layer Handles and Paths. let hs = app.session.prefs.general.path_point_size as f32 + 2.0; @@ -748,7 +749,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Track points of the current track (Tracker panel) on its layer, while that layer is selected. let mut track_hits = vec![]; let track_cur = super::tracker::viewer_track(app); - if app.ui.viewer.show_layer_controls + if app.layer_controls_visible() && let Some((tl, tu)) = track_cur && selected.contains(&tl) && let Some(layer) = comp.layer(tl) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 50031da7..4b657f52 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -590,16 +590,13 @@ pub struct UiState { pub snapping: bool, /// Tool creates shape (true) or mask (false) when a layer is selected. pub tool_creates_shape: bool, - /// Preview panel options. - pub preview_loop: bool, - pub preview_cache_first: bool, - /// Preview panel "Include Audio" (Mute Audio off). - #[serde(default = "yes")] - pub preview_audio: bool, pub start_screen: bool, /// The Home screen shows its Learn tab (tutorials) instead of the recent projects. #[serde(default)] pub home_learn: bool, + /// Align panel: Align Layers to Selection (off: to the composition). + #[serde(default)] + pub align_to_selection: bool, /// Composition ▸ Preview ▸ Cache Frames When Idle. pub cache_when_idle: bool, /// Tracker panel ▸ Motion Source chosen without a tracker yet (layer id). @@ -665,11 +662,9 @@ impl Default for UiState { stroke_width: 0.0, snapping: true, tool_creates_shape: true, - preview_loop: true, - preview_cache_first: false, - preview_audio: true, start_screen: false, home_learn: false, + align_to_selection: false, cache_when_idle: false, tracker_source: None, layer_panel: None, diff --git a/crates/ui-egui/tests/ui_preview_align.rs b/crates/ui-egui/tests/ui_preview_align.rs new file mode 100644 index 00000000..3629a02c --- /dev/null +++ b/crates/ui-egui/tests/ui_preview_align.rs @@ -0,0 +1,186 @@ +//! Headless checks for M13.8: the Preview panel's per-shortcut options drive playback (range, +//! play from, resolution, overlays, full screen, move time), the preview shortcuts pick their +//! options, and the Align panel runs `layer.align` / `layer.distribute`. + +use effectcraft_engine::Session; +use effectcraft_engine::preview::PreviewShortcut; +use effectcraft_engine::time::Tick; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::{Event, Pos2, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Preview", "width": 320, "height": 180, "frameRate": 24, "duration": 10})).unwrap(); + s.execute("layer.newSolid", json!({"name": "A", "color": "#406080", "width": 40, "height": 30})).unwrap(); + s.execute("layer.newSolid", json!({"name": "B", "color": "#806040", "width": 60, "height": 20})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, p: serde_json::Value) -> serde_json::Value { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, p).unwrap() +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + let p: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +fn set(h: &mut Harness<'_, EffectcraftApp>, p: serde_json::Value) { + h.state_mut().session.execute("playback.settings.set", p).unwrap(); +} + +#[test] +fn preview_panel_edits_the_shown_shortcut() { + let mut h = harness(); + invoke(&mut h, "window.panel", json!({"panel": "preview"})); + // The default workspace keeps the Preview panel short: maximize it. + h.state_mut().toggle_maximize(PanelKind::Preview); + h.run_steps(3); + for id in [ + "preview.shortcut", + "preview.includeVideo", + "preview.includeAudio", + "preview.includeOverlays", + "preview.includeLayerControls", + "preview.loop", + "preview.cacheBeforePlayback", + "preview.range", + "preview.playFrom", + "preview.frameRate", + "preview.skip", + "preview.resolution", + "preview.fullScreen", + "preview.playCachedFrames", + "preview.moveTimeToPreviewTime", + ] { + assert!(h.state().auto.find(id).is_some(), "{id}"); + } + assert!(h.state().session.prefs.preview.spacebar.loop_); + click(&mut h, "preview.loop"); + assert!(!h.state().session.prefs.preview.spacebar.loop_); + // Shortcut popup: Shift+Numpad 0; its options are shown and edited from now on. + click(&mut h, "preview.shortcut"); + click(&mut h, "preview.shortcut.3"); + assert_eq!(h.state().session.prefs.preview.current, PreviewShortcut::ShiftNumpad0); + click(&mut h, "preview.fullScreen"); + assert!(h.state().session.prefs.preview.shift_numpad0.full_screen); + assert!(!h.state().session.prefs.preview.spacebar.full_screen); + // Range popup → Play Around Current Time shows the pre/post-roll. + click(&mut h, "preview.range"); + click(&mut h, "preview.range.3"); + h.run_steps(2); + assert!(h.state().auto.find("preview.preRoll").is_some()); +} + +#[test] +fn playback_follows_the_shortcut_options() { + let mut h = harness(); + // Numpad 0: Work Area from its start; the time returns when the preview stops. + h.state_mut().session.execute("comp.workArea", json!({"start": 2.0, "end": 4.0})).unwrap(); + set(&mut h, json!({"shortcut": "numpad0", "moveTimeToPreviewTime": false, "resolution": "quarter", "includeOverlays": false})); + h.state_mut().session.set_time(Tick::from_seconds_f64(7.0)); + invoke(&mut h, "playback.ramPreview", json!({})); + let app = h.state(); + assert!(app.playback.playing); + assert_eq!(app.playback.shortcut, PreviewShortcut::Numpad0); + let pl = app.playback.plan.unwrap(); + assert_eq!((pl.start, pl.end, pl.first), (48, 95, 48)); + assert_eq!(app.session.time(), Tick::from_seconds_f64(2.0)); + assert_eq!(app.viewer_scale(1.0, 1.0), 0.25); + assert!(!app.overlays_visible()); + invoke(&mut h, "playback.stop", json!({})); + assert!(!h.state().playback.playing); + assert_eq!(h.state().session.time(), Tick::from_seconds_f64(7.0)); + assert!(h.state().overlays_visible()); + + // Spacebar with Full Screen: the viewer is maximized while it plays. + set(&mut h, json!({"shortcut": "spacebar", "fullScreen": true, "skip": 2})); + invoke(&mut h, "playback.toggle", json!({})); + assert_eq!(h.state().ui.maximized, Some(PanelKind::Composition)); + assert_eq!(h.state().playback.plan.unwrap().step, 3); + invoke(&mut h, "playback.toggle", json!({})); + assert_eq!(h.state().ui.maximized, None); + + // Shift+Spacebar from the keyboard plays with its own options. + let ev = Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT }; + h.input_mut().events.push(ev); + h.step(); + assert!(h.state().playback.playing); + assert_eq!(h.state().playback.shortcut, PreviewShortcut::ShiftSpacebar); + assert_eq!(h.state().playback.plan.unwrap().step, 2); +} + +#[test] +fn cache_before_playback_waits_then_plays_cached() { + let mut h = harness(); + set(&mut h, json!({"shortcut": "spacebar", "cacheBeforePlayback": true, "range": "entireDuration", "playFrom": "rangeStart"})); + invoke(&mut h, "playback.toggle", json!({})); + assert!(h.state().playback.caching); + // Pressing again while caching ("If caching, play cached frames") stops only when nothing + // is cached yet, otherwise plays the cached frames. + h.run_steps(4); + invoke(&mut h, "playback.toggle", json!({})); + let st = &h.state().playback; + assert!(!st.caching); + if st.playing { + let pl = st.plan.unwrap(); + assert!(pl.end < 239); + } +} + +#[test] +fn align_panel_runs_align_and_distribute() { + let mut h = harness(); + invoke(&mut h, "window.panel", json!({"panel": "align"})); + h.state_mut().toggle_maximize(PanelKind::Align); + h.run_steps(3); + for id in ["align.to", "align.left", "align.vcenter", "distribute.hcenter", "distribute.bottom"] { + assert!(h.state().auto.find(id).is_some(), "{id}"); + } + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); + h.state_mut().session.execute("layer.select", json!({"layers": ids})).unwrap(); + h.run_steps(2); + // (x position, width) of A and B (anchors in the middle of the solids). + let pos = |h: &Harness<'_, EffectcraftApp>| -> Vec<(f64, f64)> { + let c = h.state().session.active_comp().unwrap(); + ["A", "B"] + .iter() + .map(|n| { + let l = c.layers.iter().find(|l| l.name == *n).unwrap(); + let x = l.transform().unwrap().get("position").unwrap().value_at(Tick::ZERO).to_json()[0].as_f64().unwrap(); + (x, if *n == "A" { 40.0 } else { 60.0 }) + }) + .collect() + }; + let orig = pos(&h); + click(&mut h, "align.left"); + assert_eq!(pos(&h), vec![(20.0, 40.0), (30.0, 60.0)]); + // One undo step for the whole alignment. + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(pos(&h), orig); + // Align Layers to: Selection — right edges meet at the rightmost. + click(&mut h, "align.to"); + click(&mut h, "align.to"); + h.state_mut().ui.align_to_selection = true; + h.run_steps(2); + assert_eq!(h.state().auto.find("align.to").unwrap().label, "Selection"); + click(&mut h, "align.right"); + let p = pos(&h); + let right = |(x, w): (f64, f64)| x + w / 2.0; + let goal = orig.iter().map(|v| right(*v)).fold(f64::NEG_INFINITY, f64::max); + assert!((right(p[0]) - goal).abs() < 1e-9 && (right(p[1]) - goal).abs() < 1e-9, "{p:?}"); + // Distribute needs three layers: nothing moves. + click(&mut h, "distribute.hcenter"); + assert_eq!(pos(&h), p); +}