Merge main into M13.6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 08:03:27 -04:00
co-authored by Claude Opus 5.5
23 changed files with 2063 additions and 271 deletions
+285
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);
}
}
+8 -1
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.
+3
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
})
}
+1
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;
+4
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(());
+550
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));
}
}
}