Merge branch 'worktree-agent-a56a28d82c228c05a'

This commit is contained in:
Brandon Thomas 2026-10-03 08:02:09 -04:00
commit 0f591e0856
23 changed files with 2063 additions and 270 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

@ -299,3 +299,19 @@ pub fn extrude(outlines: &[Outline], p: &ExtrudeParams) -> Primitive {
}
prim
}
/// Gap (pixels) between stacked extrusions of one layer: paint drawn later (a stroke over its
/// fill, an upper shape group) sits this much nearer the camera per level, so coplanar front
/// caps never fight in the depth buffer and the stacking order of the 2D layer is kept.
pub const STACK_GAP: f64 = 0.02;
/// [`extrude`] at stacking level `level` (0 = bottom paint of the layer): the mesh moves
/// `level × STACK_GAP` towards the camera (−z).
pub fn extrude_stacked(outlines: &[Outline], p: &ExtrudeParams, level: usize) -> Primitive {
let mut prim = extrude(outlines, p);
let dz = (level as f64 * STACK_GAP) as f32;
for v in &mut prim.positions {
v[2] -= dz;
}
prim
}

View File

@ -360,3 +360,16 @@ fn gltf_cameras_and_punctual_lights() {
let d = lights[0].world.apply_vec(vec3(0.0, 0.0, -1.0));
assert!((d.y + 1.0).abs() < 1e-6, "{d:?}");
}
#[test]
fn stacked_extrusions_move_towards_the_camera() {
let square = Outline { outer: vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]], holes: vec![] };
let p = ExtrudeParams { bevel: BevelStyle::None, bevel_depth: 0.0, hole_bevel: 1.0, depth: 10.0 };
let base = extrude(std::slice::from_ref(&square), &p);
let up = super::extrude::extrude_stacked(std::slice::from_ref(&square), &p, 3);
assert_eq!(base.indices, up.indices);
for (a, b) in base.positions.iter().zip(&up.positions) {
assert!((a[2] - b[2] - 3.0 * super::extrude::STACK_GAP as f32).abs() < 1e-6);
assert_eq!((a[0], a[1]), (b[0], b[1]));
}
}

View File

@ -586,7 +586,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf
mod tests;
/// The filled outlines of a shape layer in layer space with their straight colours (gradient
/// fills: the colour half way along), for Advanced 3D extrusion. Strokes are not extruded.
/// fills: the colour half way along), without strokes (see [`extrusion_outlines`]).
pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec<BezPath>, [f32; 4])> {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
@ -606,6 +606,39 @@ pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<
.collect()
}
/// The painted regions of a shape layer for Advanced 3D extrusion, bottom paint first: fills
/// (gradient fills: the colour half way along) and strokes, each stroke turned into the region
/// it covers (outline of the stroke with its width, joins, caps and dashes, overlaps resolved).
pub fn extrusion_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec<BezPath>, [f32; 4])> {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
draws
.iter()
.rev()
.filter(|d| d.opacity > 0.0)
.filter_map(|d| {
let paths: Vec<BezPath> = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect();
if paths.is_empty() {
return None;
}
let (c, region) = match &d.paint {
Paint::Fill { color, .. } => (*color, paths),
Paint::Gradient { g, stroke: None, .. } => (g.sample(0.5), paths),
Paint::Stroke { color, style } => (*color, vec![stroke_region(&paths, style)?]),
Paint::Gradient { g, stroke: Some(style), .. } => (g.sample(0.5), vec![stroke_region(&paths, style)?]),
};
Some((region, [c[0], c[1], c[2], c[3] * d.opacity]))
})
.collect()
}
/// The filled region a stroke covers: its outline with self-overlaps resolved (non-zero).
pub fn stroke_region(paths: &[BezPath], style: &StrokeStyle) -> Option<BezPath> {
let o = effectcraft_path::stroke_outline(paths, style, 1.0)?;
let r = effectcraft_path::boolean::normalize(&o, FillRule::NonZero);
(!r.elements().is_empty()).then_some(r)
}
/// Layer-space bounds of a shape layer's painted contents (strokes included), without
/// rasterising.
pub fn content_bounds(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Option<kurbo::Rect> {

View File

@ -0,0 +1,123 @@
//! Advanced 3D depth of field with the camera's iris and highlight options (the same Camera
//! Options as Classic 3D: Iris Shape from Fast Rectangle to Decagon, Iris Rotation, Roundness,
//! Aspect Ratio, Diffraction Fringe, Highlight Gain / Threshold / Saturation).
//!
//! Every pixel's blur radius comes from its camera depth (the circle of confusion of the
//! resolved scene, in output pixels). The image is blurred with the Classic 3D bokeh kernel
//! ([`crate::three_d::bokeh`]) at a few radii spanning the range and each pixel blends the two
//! levels around its own radius, so an out-of-focus point spreads into exactly the iris shape
//! a Classic 3D layer shows, and in-focus pixels stay sharp. Highlights are boosted before the
//! blur (only where the image is blurred) so bright points bloom into bokeh.
use rayon::prelude::*;
use crate::Image;
use crate::three_d::bokeh::{boost_highlights, progressive_blur};
use crate::three_d::camera::Dof;
/// Largest blur radius (output pixels).
const MAX_RADIUS: f32 = 48.0;
/// Depth of field of a resolved Advanced 3D image (premultiplied) with camera depth per pixel
/// (∞ where nothing was drawn: blurred as far away).
pub fn depth_of_field(img: &Image, depth: &[f32], dof: &Dof, scale: f64) -> Image {
let radius: Vec<f32> = depth
.iter()
.map(|&z| {
let z = if z.is_finite() { z as f64 } else { 1.0e9 };
((dof.coc(z) * scale * 0.5) as f32).min(MAX_RADIUS)
})
.collect();
let rmax = radius.iter().fold(0.0f32, |a, &b| a.max(b));
if rmax < 0.5 {
return img.clone();
}
let mut src = img.clone();
if dof.highlight.gain > 0.0 {
let mut boosted = img.clone();
boost_highlights(&mut boosted, &dof.highlight);
src.data.par_iter_mut().zip(boosted.data.par_iter()).zip(radius.par_iter()).for_each(|((d, b), &r)| {
let k = (r - 0.5).clamp(0.0, 1.0);
for c in 0..4 {
d[c] += (b[c] - d[c]) * k;
}
});
}
progressive_blur(&src, &dof.iris, &radius)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::three_d::bokeh::{Highlight, Iris, bokeh_blur};
fn dot(size: u32) -> Image {
let mut img = Image::new(size, size);
let c = (size / 2) as usize;
img.data[c * size as usize + c] = [1.0, 1.0, 1.0, 1.0];
img
}
/// Out of focus everywhere with a blur radius of `r` output pixels.
fn dof(r: f64, iris: Iris) -> (Dof, Vec<f32>) {
// coc = aperture · (z − focus) / z; radius = coc / 2.
let (focus, z) = (500.0, 2000.0);
let aperture = 2.0 * r * z / (z - focus);
(Dof { focus, aperture, blur_level: 1.0, iris, highlight: Highlight::default() }, vec![z as f32; 81 * 81])
}
fn lit(img: &Image) -> Vec<bool> {
let m = img.data.iter().fold(0.0f32, |a, p| a.max(p[3]));
img.data.iter().map(|p| p[3] > m * 0.2).collect()
}
#[test]
fn iris_shapes_match_the_classic_kernel() {
for (sides, rotation, roundness, aspect) in
[(3u32, 0.0, 0.0, 1.0), (5, 20.0, 0.0, 1.0), (6, 0.0, 0.0, 1.0), (10, 0.0, 0.5, 1.0), (4, 45.0, 0.0, 2.0), (0, 0.0, 0.0, 1.0)]
{
let iris = Iris { sides, rotation, roundness, aspect, fringe: 0.0 };
let (d, depth) = dof(16.0, iris);
let ours = lit(&depth_of_field(&dot(81), &depth, &d, 1.0));
let classic = lit(&bokeh_blur(&dot(81), &iris, 16.0));
let (mut same, mut any) = (0, 0);
for (a, b) in ours.iter().zip(&classic) {
if *a || *b {
any += 1;
if a == b {
same += 1;
}
}
}
let iou = same as f64 / any as f64;
assert!(iou > 0.8, "{sides} sides, rotation {rotation}: overlap {iou:.2}");
}
}
#[test]
fn triangle_is_not_a_disc() {
let iris = Iris { sides: 3, ..Default::default() };
let (d, depth) = dof(16.0, iris);
let out = lit(&depth_of_field(&dot(81), &depth, &d, 1.0));
// One side flat (half the radius from the centre), the opposite a vertex.
let at = |x: usize| out[40 * 81 + x];
assert_ne!(at(40 - 12), at(40 + 12));
}
#[test]
fn fringe_brightens_the_rim_and_highlights_bloom() {
let mut iris = Iris { sides: 8, ..Default::default() };
let (mut d, depth) = dof(16.0, iris);
let plain = depth_of_field(&dot(81), &depth, &d, 1.0);
iris.fringe = 1.0;
d.iris = iris;
let rimmed = depth_of_field(&dot(81), &depth, &d, 1.0);
let ratio = |im: &Image| im.data[40 * 81 + 40 + 14][3] / im.data[40 * 81 + 40 + 2][3];
assert!(ratio(&rimmed) > ratio(&plain) * 1.3, "{} vs {}", ratio(&rimmed), ratio(&plain));
// Highlight gain above the threshold brightens the bloom.
d.iris.fringe = 0.0;
d.highlight = Highlight { gain: 0.5, threshold: 0.5, saturation: 0.0 };
let bright = depth_of_field(&dot(81), &depth, &d, 1.0);
assert!(bright.data[40 * 81 + 40][0] > plain.data[40 * 81 + 40][0] * 1.5);
}
}

View File

@ -6,9 +6,10 @@
//! is rasterised with a depth buffer on the GPU when an [`crate::Accelerator`] implements
//! [`crate::Accelerator::raster_3d`], otherwise by the software rasteriser ([`raster`]), at 2×2
//! supersampling. The resolve (box filter), depth of field (a gather blur by each pixel's
//! circle of confusion) and the conversion back to the working encoding run on the CPU for
//! circle of confusion, shaped by the camera's iris: [`dof`]) and the conversion back to the working encoding run on the CPU for
//! both paths, and the result is composited over the layers below the run.
pub mod dof;
pub mod raster;
pub mod scene;
pub mod shade;
@ -16,13 +17,19 @@ pub mod shade;
use effectcraft_project::Layer;
use rayon::prelude::*;
pub use dof::depth_of_field;
pub use raster::Target;
pub use scene::{EnvInfo, Light, Material, Scene, ShadowMap, TexInfo, Vertex};
use super::camera::Dof;
use super::compose::camera_for;
use crate::{EvalCtx, Image, Renderer};
/// Whether a comp draws its 3D layers with the Advanced 3D renderer: its own setting, or for a
/// collapsed precomp's layers, the setting of the outermost comp they collapse into.
pub(crate) fn active(r: &Renderer, ctx: &EvalCtx) -> bool {
r.collapse3d.map_or(ctx.comp, |c| c.parent.comp).renderer == effectcraft_project::Renderer::Advanced3D
}
/// Box-filter a supersampled target down by `k`: (premultiplied colour, mean depth of the
/// covered subsamples, ∞ where none).
pub fn resolve(t: &Target, k: u32) -> (Image, Vec<f32>) {
@ -56,68 +63,6 @@ pub fn resolve(t: &Target, k: u32) -> (Image, Vec<f32>) {
(img, depth)
}
/// Depth of field: a gather blur where each pixel collects the neighbours whose circle of
/// confusion (from their depth, in output pixels) reaches it; nearer in-focus surfaces are not
/// smeared by the blur of what is behind them.
pub fn depth_of_field(img: &Image, depth: &[f32], dof: &Dof, scale: f64) -> Image {
let (w, h) = (img.width as i64, img.height as i64);
let max_r = 48.0f32;
let radius: Vec<f32> = depth
.iter()
.map(|&z| {
let z = if z.is_finite() { z as f64 } else { 1.0e9 };
((dof.coc(z) * scale * 0.5) as f32).min(max_r)
})
.collect();
let rmax = radius.iter().fold(0.0f32, |a, &b| a.max(b));
if rmax < 0.5 {
return img.clone();
}
// Disk samples (golden-angle spiral) over the largest radius.
const N: usize = 64;
let golden = std::f32::consts::PI * (3.0 - 5.0f32.sqrt());
let taps: Vec<(f32, f32, f32)> = (1..=N)
.map(|i| {
let r = (i as f32 / N as f32).sqrt() * rmax;
let a = i as f32 * golden;
(r * a.cos(), r * a.sin(), r)
})
.collect();
let mut out = Image::new(img.width, img.height);
out.data.par_chunks_mut(w as usize).enumerate().for_each(|(y, row)| {
for x in 0..w as usize {
let i = y * w as usize + x;
let (rp, zp) = (radius[i], depth[i]);
let cw = 1.0 / (std::f32::consts::PI * rp * rp).max(1.0);
let c = img.data[i];
let mut acc = [c[0] * cw, c[1] * cw, c[2] * cw, c[3] * cw];
let mut wsum = cw;
for &(dx, dy, d) in &taps {
let (sx, sy) = (x as i64 + dx.round() as i64, y as i64 + dy.round() as i64);
if sx < 0 || sy < 0 || sx >= w || sy >= h {
continue;
}
let j = (sy * w + sx) as usize;
// A sample behind this pixel blurs no further than this pixel's own circle.
let rq = if depth[j] > zp { radius[j].min(rp) } else { radius[j] };
let cov = (rq - d + 0.5).clamp(0.0, 1.0);
if cov <= 0.0 {
continue;
}
// Each tap stands for an annulus of area ≈ π·rmax²/N.
let wq = cov / (std::f32::consts::PI * rq * rq).max(1.0) * (std::f32::consts::PI * rmax * rmax / N as f32);
let q = img.data[j];
for k in 0..4 {
acc[k] += q[k] * wq;
}
wsum += wq;
}
row[x] = acc.map(|v| v / wsum);
}
});
out
}
/// Linear → sRGB-encoded premultiplied pixels (the working encoding of non-linear projects).
pub fn encode(img: &mut Image) {
img.data.par_iter_mut().for_each(|p| {

View File

@ -212,6 +212,9 @@ pub(crate) struct Builder {
/// Per triangle: transparent and its sort depth.
tri_depth: Vec<(bool, f32)>,
cam: CameraState,
/// World of the collapsed precomp layers the current layers are drawn through (identity at
/// the top level).
outer: Mat4,
}
impl Builder {
@ -451,7 +454,7 @@ fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) {
}
_ => return,
};
let world = ctx.world_matrix(layer) * model_to_layer(unit);
let world = b.outer * ctx.world_matrix(layer) * model_to_layer(unit);
// Materials of the model (one default when it has none).
let mut mats: Vec<u32> = vec![];
let defaults = [model::Material::plain([0.8, 0.8, 0.8, 1.0], 0.0, 0.5)];
@ -532,34 +535,55 @@ pub fn extrusion_params(ctx: &EvalCtx, layer: &Layer) -> Option<model::extrude::
}
/// Extruded meshes of a text or shape layer: (mesh, straight colour, mesh space → layer
/// space). Text extrudes each character in its own space, so per-character 3D animation
/// (position, rotation, scale in Z) carries over.
/// space). Fills and strokes are both extruded (a stroke as the region it covers, bevelled like
/// the fill), stacked in the layer's paint order (see [`model::extrude::extrude_stacked`]).
/// Text extrudes each character in its own space, so per-character 3D animation (position,
/// rotation, scale in Z) carries over.
pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams) -> Vec<(Primitive, [f32; 4], Mat4)> {
let mut groups: Vec<(Vec<kurbo::BezPath>, [f32; 4], Mat4)> = vec![];
// (paths, colour, mesh → layer, stacking level)
let mut groups: Vec<(Vec<kurbo::BezPath>, [f32; 4], Mat4, usize)> = vec![];
match layer.source {
LayerSource::Text => {
if let Some(tg) = crate::text::text_geom(ctx, layer) {
for g in tg.glyphs.iter().filter(|g| g.apply_fill && g.fill[3] > 0.0) {
groups.push((vec![g.local.clone()], g.fill, g.m));
// Fill & Stroke order: all strokes over all fills (2), all fills over all strokes
// (1), else the character style's Stroke Over Fill.
let stroke_on_top = match tg.fill_stroke {
1 => false,
2 => true,
_ => tg.doc.stroke_over_fill,
};
for g in &tg.glyphs {
let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32;
if g.apply_fill && g.fill[3] * op > 0.0 {
let c = [g.fill[0], g.fill[1], g.fill[2], g.fill[3] * op];
groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top)));
}
if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 {
let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) {
let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op];
groups.push((vec![region], c, g.m, usize::from(stroke_on_top)));
}
}
}
}
}
LayerSource::Shape => {
if let Some(c) = layer.props.sub("contents") {
groups = crate::shapes::fill_outlines(ctx, layer, c).into_iter().map(|(ps, c)| (ps, c, Mat4::IDENTITY)).collect();
groups = crate::shapes::extrusion_outlines(ctx, layer, c).into_iter().enumerate().map(|(k, (ps, c))| (ps, c, Mat4::IDENTITY, k)).collect();
}
}
_ => {}
}
groups
.into_iter()
.filter_map(|(paths, color, m)| {
.filter_map(|(paths, color, m, level)| {
let cs: Vec<Vec<[f64; 2]>> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect();
let outlines = model::extrude::group_contours(cs);
if outlines.is_empty() {
return None;
}
Some((model::extrude::extrude(&outlines, p), color, m))
Some((model::extrude::extrude_stacked(&outlines, p, level), color, m))
})
.collect()
}
@ -567,7 +591,7 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude
fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams, b: &mut Builder) {
let base = card_material(ctx, layer);
let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
let world = ctx.world_matrix(layer);
let world = b.outer * ctx.world_matrix(layer);
let lin = b.s.linear_io;
for (mesh, c, local) in extruded_meshes(ctx, layer, p) {
let l = |v: f32| if lin { v } else { srgb_to_linear(v) };
@ -610,7 +634,7 @@ fn card(b: &mut Builder, buf: &Buf, world: Mat4, mut mat: Material) {
fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) {
let mut mat = card_material(ctx, layer);
mat.opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
let world = ctx.world_matrix(layer);
let world = b.outer * ctx.world_matrix(layer);
if crate::text::per_char_3d(ctx, layer) {
for (buf, m) in crate::text::per_char_planes(ctx, layer, r.opts.scale) {
let buf = r.to_blend(Arc::new(buf), None);
@ -804,6 +828,50 @@ fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 {
if r.opts.draft || (out.0 as u64 * out.1 as u64) > 8_000_000 { 1 } else { 2 }
}
/// Add one layer of a run: a model or primitive mesh, an extruded text/shape layer, the nested
/// layers of a collapsed precomp, or a textured card.
fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Builder) {
if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() {
return;
}
if let Some(item) = r.collapsed(ctx, layer) {
collapsed_layer(r, ctx, layer, item, b);
} else if layer.source.is_model() {
model_layer(r, ctx, layer, b);
} else if matches!(layer.source, LayerSource::Text | LayerSource::Shape)
&& let Some(p) = extrusion_params(ctx, layer)
{
extruded_layer(r, ctx, layer, &p, b);
} else {
card_layer(r, ctx, layer, b);
}
}
/// A collapsed precomp layer: its nested layers join this scene as real Advanced 3D geometry
/// (meshes, extrusions, cards) placed by the precomp layer's world transform, so they
/// intersect, occlude and shadow the parent's layers. Nested 2D layers lie on the precomp
/// layer's plane; nested lights and cameras are ignored (the parent's light the scene).
fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: effectcraft_project::ItemId, b: &mut Builder) {
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return };
let saved = b.outer;
b.outer = saved * ctx.world_matrix(layer);
let t = nctx.time;
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
for l in nctx.comp.layers.iter().rev() {
if !l.is_active_at(t)
|| l.is_light()
|| matches!(l.source, LayerSource::Camera)
|| (any_solo && !l.switches.solo)
|| (!r.opts.guides && l.switches.guide)
{
continue;
}
emit_layer(&sub, &nctx, l, b);
}
b.outer = saved;
}
/// Build the scene of a run of 3D layers.
pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene {
build_at(r, ctx, None, run, out)
@ -828,24 +896,17 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run:
tex_keys: HashMap::new(),
tri_depth: vec![],
cam,
outer: r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world),
};
for layer in run {
let ctx = if sub.is_some() && motion_blurred(r, base, layer) { at } else { base };
if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() {
continue;
}
if layer.source.is_model() {
model_layer(r, ctx, layer, &mut b);
} else if matches!(layer.source, LayerSource::Text | LayerSource::Shape)
&& let Some(p) = extrusion_params(ctx, layer)
{
extruded_layer(r, ctx, layer, &p, &mut b);
} else {
card_layer(r, ctx, layer, &mut b);
}
emit_layer(r, ctx, layer, &mut b);
}
b.sort_triangles();
// Lights, environment, shadows (Draft 3D: unlit, like After Effects).
// Lights, environment, shadows (Draft 3D: unlit, like After Effects). A collapsed
// precomp's layers are lit by the outermost comp's lights.
let parent = r.collapse3d.map(|c| c.parent);
let ctx = parent.as_ref().unwrap_or(ctx);
if !ctx.comp.draft_3d {
let (ls, amb, env) = lights(ctx, lin);
b.s.lights = ls;
@ -866,7 +927,7 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run:
}
// Camera matrices.
let s = r.opts.scale;
let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64);
let (cw, ch) = crate::three_d::compose::view_size(r, ctx);
let (ow, oh) = (out.0 as f64, out.1 as f64);
let (a, bb) = (2.0 * s * cam.zoom / ow, s * cw / ow - 1.0);
let (c, d) = (2.0 * s * cam.zoom / oh, s * ch / oh - 1.0);

View File

@ -425,3 +425,113 @@ fn track_mattes_in_advanced_3d() {
let img = render(&p, cid);
assert!(img.data[30 * 120 + 30][3] < 0.01 && img.data[70 * 120 + 100][3] > 0.99);
}
#[test]
fn collapsed_precomps_join_the_advanced_3d_scene() {
// A cube in a nested (Classic 3D) comp, collapsed into an Advanced 3D comp: the cube is
// real geometry lit by the parent's light, not a flat card of the nested comp (which, being
// Classic 3D, draws no meshes at all).
let (mut p, cid) = project(160, 120);
let mut nc = Comp::new(160, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
nc.renderer = R3::Classic3D;
let nid = p.add_item("N", Default::default(), None, ItemKind::Comp(nc.into()));
add(&mut p, nid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
set(l, "geometryOptions/width", Value::Scalar(60.0));
set(l, "geometryOptions/height", Value::Scalar(60.0));
set(l, "geometryOptions/depth", Value::Scalar(60.0));
set(l, "transform/rotationY", Value::Scalar(35.0));
set(l, "transform/rotationX", Value::Scalar(-25.0));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Parallel }, |_| {});
add(&mut p, cid, LayerSource::Comp { item: nid }, |l| {
l.switches.three_d = true;
l.switches.collapse = true;
});
let shades = |img: &crate::Image| {
let mut s: Vec<i32> = img.data.iter().filter(|q| q[3] > 0.99).map(|q| (q[0] * 20.0) as i32).collect();
s.sort();
s.dedup();
s
};
let img = render(&p, cid);
assert!(img.data[60 * 160 + 80][3] > 0.99, "the nested cube covers the centre");
assert!(shades(&img).len() >= 3, "faces lit by the parent's light: {:?}", shades(&img));
// The scene holds the cube's triangles (a card would be two).
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let comp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx::new(&p, cid, comp, Tick::ZERO);
let run: Vec<&effectcraft_project::Layer> = comp.layers.iter().filter(|l| l.is_3d() && !l.is_light()).collect();
let s = scene_of(&r, &ctx, &run, (160, 120));
assert!(s.indices.len() / 3 >= 12, "{} triangles", s.indices.len() / 3);
// A 2D collapsed precomp draws its 3D layers through the parent's Advanced 3D renderer too.
p.comp_mut(cid).unwrap().layers[0].switches.three_d = false;
let flat = render(&p, cid);
assert!(flat.data[60 * 160 + 80][3] > 0.99);
assert!(shades(&flat).len() >= 3, "{:?}", shades(&flat));
// Collapse off: the nested comp renders on its own (Classic 3D: no meshes).
p.comp_mut(cid).unwrap().layers[0].switches.collapse = false;
p.comp_mut(cid).unwrap().layers[0].switches.three_d = true;
assert!(render(&p, cid).data.iter().all(|q| q[3] == 0.0));
}
#[test]
fn extruded_strokes_render_as_bevelled_meshes() {
let (mut p, cid) = project(200, 120);
add(&mut p, cid, LayerSource::Text, |l| {
l.switches.three_d = true;
let doc = effectcraft_keyframe::TextDoc {
text: "I".into(),
size: 80.0,
fill: [1.0, 1.0, 1.0, 1.0],
apply_fill: true,
stroke: [1.0, 0.0, 0.0, 1.0],
apply_stroke: true,
stroke_width: 8.0,
stroke_over_fill: false,
..Default::default()
};
set(l, "text/sourceText", Value::Text(Box::new(doc)));
let mut next = 10_000u64;
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
set(l, "geometryOptions/extrusionDepth", Value::Scalar(20.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
assert_eq!(meshes.len(), 2, "fill and stroke");
let width = |m: &effectcraft_model::Primitive| {
let (a, b) = m.positions.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), q| (a.min(q[0]), b.max(q[0])));
b - a
};
let (fill, stroke) = (&meshes[0], &meshes[1]);
assert_eq!(fill.1, [1.0, 1.0, 1.0, 1.0]);
assert_eq!(stroke.1, [1.0, 0.0, 0.0, 1.0]);
// The stroke reaches half its width beyond the fill on each side, and is extruded too.
assert!((width(&stroke.0) - width(&fill.0) - 8.0).abs() < 1.0, "{} vs {}", width(&stroke.0), width(&fill.0));
assert!(stroke.0.positions.iter().any(|q| q[2] > 19.0));
// Fill Over Stroke: the fill's front cap sits in front of the stroke's.
let front = |m: &effectcraft_model::Primitive| m.positions.iter().fold(f32::INFINITY, |a, q| a.min(q[2]));
assert!(front(&fill.0) < front(&stroke.0));
// Rendered (unlit: base colours): a red rim around the white face.
let img = render(&p, cid);
let red = img.data.iter().filter(|q| q[3] > 0.99 && q[0] > 0.9 && q[1] < 0.1).count();
let white = img.data.iter().filter(|q| q[3] > 0.99 && q[1] > 0.9).count();
assert!(red > 50 && white > 50, "red {red}, white {white}");
}
#[test]
fn stroke_regions_are_rings() {
// A closed path's stroke covers a ring (the inside stays open), so an extruded stroke is a
// hollow frame around its fill.
let sq = effectcraft_path::rect([100.0, 100.0], [0.0, 0.0], 0.0);
let st = effectcraft_path::StrokeStyle { width: 10.0, ..Default::default() };
let r = crate::shapes::stroke_region(std::slice::from_ref(&sq), &st).unwrap();
let a = effectcraft_path::boolean::filled_area(&r, effectcraft_path::FillRule::NonZero);
assert!((a - (110.0 * 110.0 - 90.0 * 90.0)).abs() < 20.0, "{a}");
let outlines = effectcraft_model::extrude::group_contours(scene::contours(&r, 0.25));
assert_eq!(outlines.len(), 1);
assert_eq!(outlines[0].holes.len(), 1);
}

View File

@ -367,9 +367,9 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut
if run.is_empty() {
return;
}
// Advanced 3D comps: meshes, physically based lights, shadow maps (collapsed precomps keep
// the parent's Classic 3D planes).
if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() {
// Advanced 3D comps: meshes, physically based lights, shadow maps (a collapsed precomp's
// layers use the renderer of the comp they collapse into).
if super::adv::active(r, ctx) {
super::adv::draw_run(r, ctx, run, canvas);
return;
}
@ -694,7 +694,7 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3
if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || r.quality(l) == effectcraft_project::Quality::Wireframe) {
return None;
}
if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() {
if super::adv::active(r, ctx) {
return None;
}
let g = gather(r, ctx, run, out);

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

View File

@ -15,10 +15,8 @@ How close EffectCraft is to After Effects 2026, feature by feature, and how much
| Remaining work | ≈ 19–22 agent-hours at the pace measured so far (≈ 58 on the conservative audit scale) |
| **Wall-clock estimate** | **≈ 4–5 hours** with five agents in parallel; ≈ 3 hours for 100% of P0 + P1 |
Partial features: effect implementations (42 simplified effects), the Preview panel's settings
(range, play from, skip, frame rate, full screen), GPU coverage (Advanced 3D, more GPU effects),
the web app (no threads for viewer frames and Roto Brush), the Learn panel and Align to Selection /
Distribute, Advanced 3D (iris shapes, collapsed precomps, extruded strokes), ScriptUI resource
Partial features: effect implementations (42 simplified effects), GPU coverage (Advanced 3D, more
GPU effects), the web app (no threads for viewer frames and Roto Brush), the Learn panel, ScriptUI resource
strings and `onDraw`, Essential Graphics font and scale controls, and the learned-model quality of
Roto Brush and face tracking (both are classical). Menu items still absent: Save Frame As ▸
Photoshop Layers / ProEXR, Open in Essential Graphics, Vanishing Point import, Watch Folder.
@ -131,12 +129,12 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Animation | 70% | 9.0 | puppet depth beyond pins and recording (puppet pin recording with Record Options landed in M13.1), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
| Text | ≈ 94% | 0.5 | no extruded strokes, variable-axis animation changes outlines but not advances (OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
| Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) |
| 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
| Effects | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
| Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
| Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
| Preview | 66% | 4.0 | GPU bokeh depth of field, wireframes and Advanced 3D compositing (Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Preview | 72% | 3.5 | GPU bokeh depth of field, wireframes and Advanced 3D compositing (the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Tracking | ≈ 96% | 0.3 | face tracking is a classical (skin model + feature components + shape model) fitter, not a learned detector: profile views and occluded faces are weak; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) |
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
@ -176,7 +174,29 @@ Tate-Chu-Yoko, forced LTR paragraphs, the editing caret on animated / path text
style runs; the Layer Style dialog (LYR-9); Graph Editor snapping to markers and layer ends
(ANM-4; the reference graph existed); viewer ROI resize handles, Pan Behind snapping and 3D
Reference Axes; Animate Text ▸ Variable Font Axes. Remaining in these rows: Lottie can't carry
taper/wave, Advanced 3D's DOF has no iris shapes, and variable axes don't change advances.
taper/wave and variable axes don't change advances (Advanced 3D's DOF has iris shapes since
M13.8).
## Update: M13.8 Preview panel, Align panel, Advanced 3D completion
- **Preview panel (PRV-1)**: Shortcut popup (Spacebar, Shift+Spacebar, Numpad 0, Shift+Numpad
0, Alt+Numpad 0), each shortcut with its own saved options (settings `preview`): Include
Video / Audio / Overlays / Layer Controls, Loop, Cache Before Playback, Range (Work Area,
Work Area Extended By Current Time, Entire Duration, Play Around Current Time with pre/post
roll), Play From (Range Start / Current Time), Frame Rate (Auto or a rate), Skip, Resolution
(Auto, Full … Quarter, Custom), Full Screen, "If caching, play cached frames" and "Move time to
preview time". `playback.settings.get/set`; playback follows the plan (range, start, skip grid,
rate, loop, cache-first, audio-only, resolution override, hidden overlays / layer controls,
maximized viewer); the keys trigger their shortcut (`playback.toggle {shortcut?}`,
`playback.ramPreview`, `playback.preview.shiftSpacebar|shiftNumpad0|altNumpad0`). Full Screen
maximizes the Composition panel rather than taking over the display.
- **Align panel (UI-1)**: `layer.align {edge, to: composition|selection}` and `layer.distribute
{mode}` (edges or centres) on content bounds through transform and parents, one undo step;
Align Layers to dropdown and the Distribute Layers row.
- **Advanced 3D (3D-3)**: depth of field with the camera's iris and highlight options (the
Classic 3D bokeh kernel, by each pixel's depth); collapsed precomps add their nested layers
as real geometry lit by the parent (2D collapsed precomps draw nested 3D layers with the
parent's renderer); text and shape strokes extrude as bevelled meshes in paint order.
## Update: M13.5 UI completion & fidelity