Files
effectcraft/crates/engine/src/commands/viewer_cmds.rs
T
Brandon Thomas 86b8e24417 Merge branch 'main' into worktree-agent-a15cecfec57d5290d
# Conflicts:
#	crates/effects/src/ocio.rs
#	crates/engine/src/commands/stubs.rs
#	docs/parity.md
2026-10-03 02:26:19 -04:00

424 lines
19 KiB
Rust

//! Composition viewer state that agents and the CLI can drive too: snapping, Show Channel and
//! exposure, snapshots, Fast Previews, guides dragged out of the rulers, and the Pen tool for
//! shape layers (`shape.newPath`).
use effectcraft_keyframe::ShapePath;
use effectcraft_project::LayerSource;
use effectcraft_project::build::{self, Ids};
use effectcraft_render::RenderOpts;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p};
use crate::viewer::{Channel, SimProfile, Simulation, Snapshot};
use crate::{EngineError, Result, Session, VertexRef, cmd, query};
/// Composition ▸ Preview ▸ Fast Previews modes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum FastPreviews {
#[default]
Off,
AdaptiveResolution,
Draft,
FastDraft,
Wireframe,
}
impl FastPreviews {
pub const ALL: [FastPreviews; 5] =
[FastPreviews::Off, FastPreviews::AdaptiveResolution, FastPreviews::Draft, FastPreviews::FastDraft, FastPreviews::Wireframe];
pub fn id(self) -> &'static str {
match self {
FastPreviews::Off => "off",
FastPreviews::AdaptiveResolution => "adaptive",
FastPreviews::Draft => "draft",
FastPreviews::FastDraft => "fastDraft",
FastPreviews::Wireframe => "wireframe",
}
}
pub fn label(self) -> &'static str {
match self {
FastPreviews::Off => "Off (Final Quality)",
FastPreviews::AdaptiveResolution => "Adaptive Resolution",
FastPreviews::Draft => "Draft",
FastPreviews::FastDraft => "Fast Draft",
FastPreviews::Wireframe => "Wireframe",
}
}
pub fn from_name(s: &str) -> Option<FastPreviews> {
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
FastPreviews::ALL.into_iter().find(|m| m.id().to_ascii_lowercase() == n || m.label().to_ascii_lowercase().replace(' ', "").starts_with(&n))
}
/// Render options for the viewer: (draft quality, resolution factor while interacting).
pub fn render(self, interacting: bool) -> (bool, f64) {
match self {
FastPreviews::Off => (false, 1.0),
FastPreviews::AdaptiveResolution => (false, if interacting { 0.25 } else { 1.0 }),
FastPreviews::Draft => (true, 1.0),
FastPreviews::FastDraft => (true, if interacting { 0.25 } else { 1.0 }),
FastPreviews::Wireframe => (true, 1.0),
}
}
}
/// Viewer display options (Show Channel, exposure, snapshot, Fast Previews, display colour).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ViewOptions {
pub channel: Channel,
/// Show Channel ▸ Colorize: single channels shown in their colour instead of gray.
pub colorized: bool,
/// Adjust Exposure, in stops.
pub exposure: f32,
/// Show Snapshot (F5 held): the viewer shows the snapshot instead of the frame.
pub show_snapshot: bool,
pub fast_previews: FastPreviews,
/// View ▸ Use Display Color Management (with a working space): the frame is converted
/// from the working space to the display; off shows the working space's numbers.
pub display_color_management: bool,
/// View ▸ Simulate Output.
pub simulation: Simulation,
/// The profile View ▸ Simulate Output ▸ Custom... uses.
pub custom_simulation: Simulation,
}
impl Default for ViewOptions {
fn default() -> Self {
ViewOptions {
channel: Channel::default(),
colorized: false,
exposure: 0.0,
show_snapshot: false,
fast_previews: FastPreviews::default(),
display_color_management: true,
simulation: Simulation::default(),
custom_simulation: Simulation { profile: SimProfile::Linear, preserve_rgb: true },
}
}
}
/// View ▸ Split with New Locked Viewer: a second Composition viewer that keeps showing one
/// comp and 3D view whatever comp is active.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct LockedViewer {
pub comp: effectcraft_project::ItemId,
pub view: effectcraft_render::three_d::View3D,
}
fn snapping(s: &mut Session, p: &Value) -> Result<Value> {
s.state.snapping = b_p(p, "value").unwrap_or(!s.state.snapping);
Ok(json!(s.state.snapping))
}
fn channel(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "channel").unwrap_or("rgb");
let c = Channel::from_name(name).ok_or_else(|| bad("view.channel", format!("unknown channel `{name}` (rgb|red|green|blue|alpha|rgbStraight)")))?;
// Choosing the active single channel again returns to RGB, as in After Effects.
let c = if c == s.state.viewer.channel && c != Channel::Rgb && b_p(p, "toggle").unwrap_or(false) { Channel::Rgb } else { c };
s.state.viewer.channel = c;
if let Some(v) = b_p(p, "colorized") {
s.state.viewer.colorized = v;
}
Ok(json!({"channel": c.id(), "colorized": s.state.viewer.colorized}))
}
fn exposure(s: &mut Session, p: &Value) -> Result<Value> {
let cur = s.state.viewer.exposure as f64;
let v = match (f_p(p, "stops"), f_p(p, "delta")) {
(Some(v), _) => v,
(None, Some(d)) => cur + d,
_ => return Err(bad("view.exposure", "pass `stops` or `delta`")),
};
s.state.viewer.exposure = v.clamp(-40.0, 40.0) as f32;
Ok(json!(s.state.viewer.exposure))
}
fn reset_exposure(s: &mut Session, _: &Value) -> Result<Value> {
s.state.viewer.exposure = 0.0;
Ok(json!(0.0))
}
fn take_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = s.time_of(cid);
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.05, 1.0);
let img = s.render(cid, t, RenderOpts { scale, guides: true, view: s.view_camera(cid).filter(|_| comp.has_3d()), ..Default::default() });
let (w, h) = (img.width, img.height);
s.snapshot = Some(Snapshot { comp: cid, time: t, scale, image: std::sync::Arc::new(img) });
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"width": w, "height": h, "time": t.seconds()}))
}
fn has_snapshot(s: &Session) -> std::result::Result<(), String> {
if s.snapshot.is_some() { Ok(()) } else { Err("take a snapshot first (Shift+F5)".into()) }
}
fn show_snapshot(s: &mut Session, p: &Value) -> Result<Value> {
s.state.viewer.show_snapshot = b_p(p, "value").unwrap_or(!s.state.viewer.show_snapshot);
Ok(json!(s.state.viewer.show_snapshot))
}
fn fast_previews(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "mode").unwrap_or("off");
let m =
FastPreviews::from_name(name).ok_or_else(|| bad("view.fastPreviewMode", format!("unknown mode `{name}` (off|adaptive|draft|fastDraft|wireframe)")))?;
s.state.viewer.fast_previews = m;
Ok(json!(m.id()))
}
fn guide_index(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::ItemId, usize)> {
let cid = comp_id(s, p)?;
let n = s.project.comp(cid).ok_or(EngineError::NoComp)?.guides.len();
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `index`"))? as usize;
if i >= n {
return Err(bad(cmd, format!("no guide {i} (the comp has {n})")));
}
Ok((cid, i))
}
fn move_guide(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, i) = guide_index(s, p, "view.moveGuide")?;
let pos = f_p(p, "position").ok_or_else(|| bad("view.moveGuide", "missing `position` (comp px)"))?;
s.edit("Move Guide", merge_p(p), |proj, _| {
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides[i].position = pos;
Ok(())
})?;
Ok(json!(pos))
}
fn remove_guide(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, i) = guide_index(s, p, "view.removeGuide")?;
s.edit("Remove Guide", None, |proj, _| {
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.guides.remove(i);
Ok(())
})?;
Ok(Value::Null)
}
fn pts(v: Option<&Value>) -> Vec<[f64; 2]> {
v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| Some([p.get(0)?.as_f64()?, p.get(1)?.as_f64()?])).collect()).unwrap_or_default()
}
fn rgba(v: Option<&Value>, d: [f64; 4]) -> [f64; 4] {
match v {
Some(Value::Array(a)) => {
let g = |i: usize, d: f64| a.get(i).and_then(Value::as_f64).unwrap_or(d);
[g(0, d[0]), g(1, d[1]), g(2, d[2]), g(3, 1.0)]
}
Some(Value::String(h)) => effectcraft_color::Rgba::from_hex(h).map(|c| [c.r as f64, c.g as f64, c.b as f64, 1.0]).unwrap_or(d),
_ => d,
}
}
/// Pen tool on a shape layer: a new Shape group ("Shape n" with Path 1, Stroke 1 and Fill 1) on
/// the given / selected shape layer, or on a new shape layer when none is given. Vertices are
/// in the layer's space, or comp space (`space: "comp"`, the default for a new layer).
fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let mut v = pts(p.get("vertices"));
if v.is_empty() {
return Err(bad("shape.newPath", "need `vertices` [[x,y], …]"));
}
let n = v.len();
let mut ins = pts(p.get("inTangents"));
let mut outs = pts(p.get("outTangents"));
ins.resize(n, [0.0; 2]);
outs.resize(n, [0.0; 2]);
let target = match p.get("layer") {
Some(l) => Some(super::resolve_layer(&comp, l).ok_or_else(|| bad("shape.newPath", format!("no layer {l}")))?),
None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)),
};
if let Some(l) = target.and_then(|l| comp.layer(l))
&& !matches!(l.source, LayerSource::Shape)
{
return Err(bad("shape.newPath", "the layer is not a shape layer"));
}
let comp_space = str_p(p, "space").map(|s| s == "comp").unwrap_or(target.is_none());
if comp_space {
// Comp → layer space of the target (a new layer sits at the comp centre, unrotated).
let inv = match target.and_then(|l| comp.layer(l)) {
Some(l) => {
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time_of(cid));
ctx.layer_to_comp(l).0.inverse().unwrap_or(effectcraft_geom::Mat3::IDENTITY)
}
None => effectcraft_geom::Mat3::translate(effectcraft_geom::vec2(-(comp.width as f64) / 2.0, -(comp.height as f64) / 2.0)),
};
for q in &mut v {
let r = inv.apply(effectcraft_geom::vec2(q[0], q[1]));
*q = [r.x, r.y];
}
for t in ins.iter_mut().chain(outs.iter_mut()) {
let r = inv.apply_vec(effectcraft_geom::vec2(t[0], t[1]));
*t = [r.x, r.y];
}
}
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() };
let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0]));
let width = f_p(p, "strokeWidth").unwrap_or(2.0);
let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0]));
let name = str_p(p, "name").map(str::to_string);
let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| {
let lid = match target {
Some(l) => l,
None => {
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
let lname = name.clone().unwrap_or_else(|| format!("Shape Layer {}", count + 1));
let l = build::layer(proj, &comp, &lname, LayerSource::Shape, (comp.width, comp.height), None);
super::layer::insert_layer(proj, st, cid, l)?
}
};
let mut next = proj.next_id;
let l = super::layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("shape.newPath", "the layer has no contents"))?;
let k = contents.groups().filter(|g| g.match_id == "group").count();
let mut ids = Ids(&mut next);
let pg = build::shape_path(&mut ids, path);
let path_uid = pg.uid;
let mut items = vec![pg];
if let Some(c) = stroke {
items.push(build::shape_stroke(&mut ids, c, width));
}
if let Some(c) = fill {
items.push(build::shape_fill(&mut ids, c));
}
let g = build::shape_group(&mut ids, &format!("Shape {}", k + 1), items);
let group = g.uid;
contents.children.insert(0, g.into());
proj.next_id = next;
st.selected_layers = vec![lid];
st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }];
Ok((lid, group, path_uid))
})?;
Ok(json!({"layer": lid.0, "group": group, "path": path_uid}))
}
/// Snapshot image as straight 8-bit RGBA rows (for frontends / `--out`).
pub fn snapshot_rgba8(s: &Session) -> Option<(u32, u32, Vec<u8>)> {
let snap = s.snapshot.as_ref()?;
let img = &snap.image;
let mut out = Vec::with_capacity(img.data.len() * 4);
for p in &img.data {
let a = p[3].clamp(0.0, 1.0);
let un = |c: f32| if a > 0.0 { (c / a).clamp(0.0, 1.0) } else { 0.0 };
out.extend([un(p[0]), un(p[1]), un(p[2]), a].map(|v| (v * 255.0 + 0.5) as u8));
}
Some((img.width, img.height, out))
}
fn shape_layer_or_none(s: &Session) -> std::result::Result<(), String> {
has_comp(s)
}
// ---------------------------------------------------------------- display colour, locked viewer
/// View ▸ Use Display Color Management (toggle, or `value`).
fn display_cm(s: &mut Session, p: &Value) -> Result<Value> {
let v = b_p(p, "value").unwrap_or(!s.state.viewer.display_color_management);
s.state.viewer.display_color_management = v;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"displayColorManagement": v, "workingSpace": s.project.settings.working_space.map(|w| w.id()), "display": s.prefs.previews.display_profile}))
}
/// View ▸ Simulate Output ▸ (profile). `custom` uses (and with `space` sets) the Custom profile.
fn simulate_output(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "view.simulateOutput";
let name = str_p(p, "profile").unwrap_or("none");
let ids = SimProfile::ALL.iter().map(|p| p.id()).collect::<Vec<_>>().join("|");
let sim = if name.eq_ignore_ascii_case("custom") {
let mut c = s.state.viewer.custom_simulation;
if let Some(sp) = str_p(p, "space") {
c.profile = SimProfile::parse(sp).ok_or_else(|| bad(cmd, format!("space: {ids}")))?;
}
if let Some(v) = b_p(p, "preserveRgb") {
c.preserve_rgb = v;
}
s.state.viewer.custom_simulation = c;
c
} else {
let profile = SimProfile::parse(name).ok_or_else(|| bad(cmd, format!("profile: {ids}|custom")))?;
Simulation { profile, preserve_rgb: b_p(p, "preserveRgb").unwrap_or(false) }
};
s.state.viewer.simulation = sim;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"profile": sim.profile.id(), "label": sim.profile.label(), "preserveRgb": sim.preserve_rgb}))
}
/// View ▸ Split with New Locked Viewer: lock a second viewer to the comp (default the active
/// one) and its current 3D view (or `view`).
fn split_locked(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let view = match str_p(p, "view") {
Some(v) => effectcraft_render::three_d::View3D::from_id(v).ok_or_else(|| bad("view.splitLockedViewer", format!("unknown view `{v}`")))?,
None => s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(),
};
s.state.locked_viewer = Some(LockedViewer { comp: cid, view });
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"comp": cid.0, "view": view.id()}))
}
fn close_locked(s: &mut Session, _: &Value) -> Result<Value> {
let was = s.state.locked_viewer.take().is_some();
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"closed": was}))
}
fn has_locked(s: &Session) -> std::result::Result<(), String> {
if s.state.locked_viewer.is_some() { Ok(()) } else { Err("no locked viewer".into()) }
}
/// The viewer's colour settings and what they do.
fn display_color_state(s: &mut Session, _: &Value) -> Result<Value> {
let v = &s.state.viewer;
Ok(json!({
"displayColorManagement": v.display_color_management,
"simulation": {"profile": v.simulation.profile.id(), "preserveRgb": v.simulation.preserve_rgb},
"display": s.prefs.previews.display_profile,
"workingSpace": s.project.settings.working_space.map(|w| w.id()),
"active": crate::viewer::DisplayColor::of(s).is_some(),
"lockedViewer": s.state.locked_viewer.map(|l| json!({"comp": l.comp.0, "view": l.view.id()})),
}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping),
cmd!("view.displayColorManagement", "Use Display Color Management", ["View"], None, "{value?}", always, display_cm),
cmd!(
"view.simulateOutput",
"Simulate Output",
[],
None,
"{profile: none|rec709|ntsc|pal|mac18|srgb|rec2020|p3|linear|custom, preserveRgb?, space? (custom)}",
always,
simulate_output
),
cmd!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], None, "{comp?, view?: 3D view id}", has_comp, split_locked),
cmd!("view.closeLockedViewer", "Close Locked Viewer", [], None, "{}", has_locked, close_locked),
query!(
"view.displayColor",
"Viewer Color State",
"{} → display colour management, output simulation, display profile, locked viewer",
display_color_state
),
cmd!("view.channel", "Show Channel", [], None, "{channel: rgb|red|green|blue|alpha|rgbStraight, colorized?, toggle?}", always, channel),
cmd!("view.exposure", "Adjust Exposure", [], None, "{stops? | delta?}", always, exposure),
cmd!("view.resetExposure", "Reset Exposure", [], None, "{}", always, reset_exposure),
cmd!("view.takeSnapshot", "Take Snapshot", [], Some("Shift+F5"), "{comp?, scale?: 0.05..1}", has_comp, take_snapshot),
cmd!("view.showSnapshot", "Show Snapshot", [], None, "{value?}", has_snapshot, show_snapshot),
cmd!("view.fastPreviewMode", "Fast Previews", [], None, "{mode: off|adaptive|draft|fastDraft|wireframe}", always, fast_previews),
cmd!("view.moveGuide", "Move Guide", [], None, "{comp?, index, position (comp px), merge?}", has_comp, move_guide),
cmd!("view.removeGuide", "Remove Guide", [], None, "{comp?, index}", has_comp, remove_guide),
cmd!(
"shape.newPath",
"Pen Tool (Shape Path)",
[],
None,
"{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, space?: comp|layer, fill?: [r,g,b]|#hex|false, stroke?, strokeWidth?, name?}",
shape_layer_or_none,
new_path
),
]
}