M13.8: Preview panel shortcuts and Align panel commands

Preview panel: five shortcuts (Spacebar, Shift+Spacebar, Numpad 0, Shift+Numpad 0,
Alt+Numpad 0) each with saved options (include video/audio/overlays/layer controls, loop,
cache before playback, range incl. play around current time, play from, frame rate, skip,
resolution, full screen, play cached frames, move time to preview time), persisted in
prefs; playback.settings.get/set; the playback loop follows the preview plan.

Align panel: layer.align {edge, to} and layer.distribute {mode} engine commands (bounds
through transform and parents, one undo step); Align Layers to dropdown and Distribute row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 07:38:46 -04:00
parent 6da4e07438
commit 553a51316c
14 changed files with 1641 additions and 169 deletions

View File

@ -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<Vec<Placed>> {
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<Value> {
let t = s.time();
let moved: Vec<u64> = 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<Value> {
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<Value> {
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<CommandSpec> {
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);
}
}

View File

@ -36,7 +36,14 @@ pub fn specs() -> Vec<CommandSpec> {
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.

View File

@ -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
})
}

View File

@ -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;

View File

@ -281,6 +281,8 @@ pub struct Prefs {
pub recent_presets: Vec<String>,
/// Character panel: recently used font families, newest first.
pub recent_fonts: Vec<String>,
/// 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(());

View File

@ -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?,
//! <field>: 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<Self> {
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<u32> {
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<f64>,
/// 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<i64> {
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<i64> {
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<Item = i64> + '_ {
(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<PreviewShortcut> {
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<Value> {
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<Value> {
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<CommandSpec> {
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));
}
}
}

View File

@ -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<effectcraft_engine::preview::PreviewPlan>,
/// 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<u32>,
/// 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<Option<PanelKind>>,
/// Audio-only previews: the frame under the audible sample (the current time stays put).
pub audio_frame: Option<i64>,
}
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<i64> = (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));
}

View File

@ -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<bool>
"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.")))
}

View File

@ -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()
}

View File

@ -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<serde_json::Value> = 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<String> = 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<String> = 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<String> = 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<String> = 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.<key>` and `preview.<key>.<i>`);
/// returns the chosen index.
fn dropdown_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, key: &str, text: &str, items: &[String], current: Option<usize>) -> Option<usize> {
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) {

View File

@ -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);
}
}

View File

@ -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)

View File

@ -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,

View File

@ -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<u64> = 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);
}