mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 06:27:11 -06:00
Merge main into text editing (M9.10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -521,6 +521,10 @@ fn select_label_group(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let what = str_p(p, "what").unwrap_or("all").to_string();
|
||||
if matches!(what.as_str(), "all" | "snapshot") {
|
||||
s.snapshot = None;
|
||||
s.state.viewer.show_snapshot = false;
|
||||
}
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
s.toast(format!("Purged {what} cache"));
|
||||
Ok(json!({"purged": what}))
|
||||
|
||||
@@ -140,6 +140,18 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(l) = p.get("linearize").and_then(Value::as_bool) {
|
||||
proj.settings.linearize = l;
|
||||
}
|
||||
if let Some(l) = p.get("blendLinear").or_else(|| p.get("blendColorsUsing1Gamma")).and_then(Value::as_bool) {
|
||||
proj.settings.blend_linear = l;
|
||||
}
|
||||
if let Some(w) = p.get("workingSpace") {
|
||||
proj.settings.working_space = match w.as_str() {
|
||||
None | Some("none" | "None" | "") => None,
|
||||
Some(n) => Some(
|
||||
effectcraft_project::ColorSpace::parse(n)
|
||||
.ok_or_else(|| bad("file.projectSettings", format!("workingSpace: none|srgb|rec709|rec2020|p3, not `{n}`")))?,
|
||||
),
|
||||
};
|
||||
}
|
||||
if let Some(t) = str_p(p, "timeDisplay") {
|
||||
proj.settings.time_display =
|
||||
if t.eq_ignore_ascii_case("frames") { effectcraft_project::TimeDisplayStyle::Frames } else { effectcraft_project::TimeDisplayStyle::Timecode };
|
||||
@@ -172,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Project Settings...",
|
||||
["File"],
|
||||
Some("Cmd+Alt+Shift+K"),
|
||||
"{bitDepth?: 8|16|32, linearize?, timeDisplay?: timecode|frames}",
|
||||
"{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, timeDisplay?: timecode|frames}",
|
||||
always,
|
||||
project_settings
|
||||
),
|
||||
|
||||
@@ -106,6 +106,7 @@ fn placeholder_footage(p: &Value) -> Footage {
|
||||
codec: "Placeholder".into(),
|
||||
missing: true,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +414,15 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let par = f_p(p, "pixelAspect");
|
||||
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
|
||||
let profile = match str_p(p, "colorProfile") {
|
||||
Some("auto" | "none" | "") => Some(None),
|
||||
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
|
||||
Some(cs) => Some(Some(cs)),
|
||||
None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
s.edit("Interpret Footage", None, |proj, _| {
|
||||
for i in &items {
|
||||
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
|
||||
@@ -431,6 +441,9 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(x) = par {
|
||||
f.pixel_aspect = x;
|
||||
}
|
||||
if let Some(c) = profile {
|
||||
f.color_profile = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -634,7 +647,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?}",
|
||||
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
|
||||
@@ -42,6 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fe!("view.res.half", "Half", ["View", "Resolution"], Some("Cmd+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.third", "Third", ["View", "Resolution"], None, "{}", has_comp),
|
||||
fe!("view.res.quarter", "Quarter", ["View", "Resolution"], Some("Cmd+Alt+Shift+J"), "{}", has_comp),
|
||||
fe!("view.res.custom", "Custom...", ["View", "Resolution"], None, "{factor?: 1..40 (render every n-th pixel)}", has_comp),
|
||||
fe!("view.rulers", "Show Rulers", ["View"], Some("Cmd+R"), "{value?}", always),
|
||||
fe!("view.panelBackground", "Panel Background Color", [], None, "{color?: black|darkGray|mediumGray|lightGray|white|custom|#hex, pick?: true}", always),
|
||||
fe!("view.guides", "Show Guides", ["View"], Some("Cmd+;"), "{value?}", always),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Keyframe geometry edits from the viewer and the Graph Editor: the Graph Editor transform box
|
||||
//! (scale / move selected keys in time and value; Alt-drag of a key group in the timeline) and
|
||||
//! spatial Bezier tangents dragged on a motion path.
|
||||
|
||||
use effectcraft_keyframe::Keyframe;
|
||||
use effectcraft_project::{LayerId, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::prop::prop_ref;
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, merge_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
fn v3(v: Option<&Value>) -> Option<[f64; 3]> {
|
||||
let a = v?.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)])
|
||||
}
|
||||
|
||||
/// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first
|
||||
/// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by
|
||||
/// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames.
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let sel = s.state.selected_keys.clone();
|
||||
let ts = f_p(p, "timeScale").unwrap_or(1.0);
|
||||
let toff = f_p(p, "timeOffset").unwrap_or(0.0);
|
||||
let vs = f_p(p, "valueScale").unwrap_or(1.0);
|
||||
let voff = f_p(p, "valueOffset").unwrap_or(0.0);
|
||||
let va = f_p(p, "valueAnchor").unwrap_or(0.0);
|
||||
// `dim` (one dimension) or `dims` (several); none = every dimension.
|
||||
let dims: Option<Vec<usize>> = match (p.get("dim").and_then(Value::as_u64), p.get("dims").and_then(Value::as_array)) {
|
||||
(Some(d), _) => Some(vec![d as usize]),
|
||||
(None, Some(a)) => Some(a.iter().filter_map(Value::as_u64).map(|d| d as usize).collect()),
|
||||
_ => None,
|
||||
};
|
||||
if !ts.is_finite() || ts <= 0.0 {
|
||||
return Err(bad("keys.transform", "`timeScale` must be positive"));
|
||||
}
|
||||
// Default time anchor: the earliest selected key (comp time).
|
||||
let first = sel.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time))).min().unwrap_or(Tick::ZERO);
|
||||
let ta = f_p(p, "timeAnchor").map(Tick::from_seconds_f64).unwrap_or(first);
|
||||
let map_t = |ct: Tick| -> Tick {
|
||||
let x = ta.seconds() + (ct.seconds() - ta.seconds()) * ts + toff;
|
||||
fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0)))
|
||||
};
|
||||
let touch_values = vs != 1.0 || voff != 0.0;
|
||||
s.edit("Transform Keyframes", merge_p(p), |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &sel {
|
||||
groups.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
let mut new_sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(layer) = comp.layer_mut(lid) else { continue };
|
||||
let (start, stretch) = (layer.start_time, layer.stretch);
|
||||
let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) };
|
||||
let to_layer = |ct: Tick| {
|
||||
let d = ct - start;
|
||||
if (stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / stretch) as i64) }
|
||||
};
|
||||
let Some(pr) = layer.props.find_mut(uid) else { continue };
|
||||
let (mut moved, mut kept): (Vec<Keyframe>, Vec<Keyframe>) = pr.keys.drain(..).partition(|k| times.contains(&k.time));
|
||||
for k in &mut moved {
|
||||
k.time = to_layer(map_t(to_comp(k.time)));
|
||||
if touch_values {
|
||||
let mut c = k.value.components();
|
||||
for (d, v) in c.iter_mut().enumerate() {
|
||||
if dims.as_ref().is_none_or(|x| x.contains(&d)) {
|
||||
*v = va + (*v - va) * vs + voff;
|
||||
}
|
||||
}
|
||||
k.value = k.value.with_components(&c);
|
||||
}
|
||||
}
|
||||
// Transformed keys win over unselected keys at the same time.
|
||||
kept.retain(|k| !moved.iter().any(|m| m.time == k.time));
|
||||
moved.sort_by_key(|k| k.time);
|
||||
moved.dedup_by_key(|k| k.time);
|
||||
for k in &moved {
|
||||
new_sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
|
||||
}
|
||||
kept.extend(moved);
|
||||
kept.sort_by_key(|k| k.time);
|
||||
pr.keys = kept;
|
||||
effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial);
|
||||
}
|
||||
st.selected_keys = new_sel;
|
||||
Ok(json!({"keys": st.selected_keys.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Spatial tangents of one key of a spatial property (motion path handles), relative to the key
|
||||
/// value. Setting one side only mirrors it to the other unless `break` is true; the key stops
|
||||
/// being auto Bezier.
|
||||
fn spatial_tangents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "keys.setSpatialTangents")?;
|
||||
let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.setSpatialTangents", "missing `time` (layer s)"))?);
|
||||
let (tin, tout) = (v3(p.get("in")), v3(p.get("out")));
|
||||
if tin.is_none() && tout.is_none() {
|
||||
return Err(bad("keys.setSpatialTangents", "pass `in` and/or `out` [dx, dy, dz?]"));
|
||||
}
|
||||
let brk = b_p(p, "break").unwrap_or(false);
|
||||
s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?;
|
||||
if !pr.spatial {
|
||||
return Err(bad("keys.setSpatialTangents", "not a spatial property"));
|
||||
}
|
||||
let i = pr.keys.iter().position(|k| k.time == t).or_else(|| pr.keys.iter().enumerate().min_by_key(|(_, k)| (k.time.0 - t.0).abs()).map(|(i, _)| i));
|
||||
let i = i.ok_or_else(|| bad("keys.setSpatialTangents", "the property has no keyframes"))?;
|
||||
let (cur_in, cur_out) = effectcraft_keyframe::spatial_tangents(&pr.keys, i);
|
||||
let k = &mut pr.keys[i];
|
||||
let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]];
|
||||
let (ni, no) = match (tin, tout) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
(Some(a), None) => (a, if brk { cur_out } else { neg(a) }),
|
||||
(None, Some(b)) => (if brk { cur_in } else { neg(b) }, b),
|
||||
(None, None) => (cur_in, cur_out),
|
||||
};
|
||||
k.spatial_auto = false;
|
||||
k.spatial_continuous = !brk && tin.is_some() != tout.is_some();
|
||||
k.spatial_in = ni;
|
||||
k.spatial_out = no;
|
||||
effectcraft_keyframe::retime_roving(&mut pr.keys, true);
|
||||
Ok(json!({"in": ni, "out": no}))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"keys.transform",
|
||||
"Transform Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}",
|
||||
has_keys,
|
||||
transform
|
||||
),
|
||||
cmd!(
|
||||
"keys.setSpatialTangents",
|
||||
"Edit Spatial Tangents",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, time (layer s), in?: [dx,dy,dz?], out?, break?, merge?}",
|
||||
has_comp,
|
||||
spatial_tangents
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -607,6 +607,57 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like
|
||||
/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive
|
||||
/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and
|
||||
/// layer markers are in layer time, so they travel with the source.
|
||||
fn slip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let delta = match (p.get("frames").and_then(Value::as_i64), f_p(p, "delta")) {
|
||||
(Some(f), _) => fr.tick_of(f),
|
||||
(None, Some(d)) => fr.snap_nearest(Tick::from_seconds_f64(d)),
|
||||
_ => return Err(bad("layer.slip", "delta (seconds) or frames required")),
|
||||
};
|
||||
// Allowed shift per layer: the source span [lo, hi] in comp time must keep covering in..out.
|
||||
let mut shifts: Vec<(LayerId, Tick)> = Vec::new();
|
||||
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
|
||||
if l.props.get("timeRemap").is_some() {
|
||||
continue;
|
||||
}
|
||||
let dur = match &l.source {
|
||||
LayerSource::Footage { item } => match s.project.item(*item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still => Some(Tick(f.duration.0 * f.loop_count.max(1) as i64)),
|
||||
_ => None,
|
||||
},
|
||||
LayerSource::Comp { item } => s.project.comp(*item).map(|c| c.duration),
|
||||
_ => None,
|
||||
};
|
||||
let Some(dur) = dur else { continue };
|
||||
let (a, b) = (l.comp_time(Tick::ZERO), l.comp_time(dur));
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
let (min_d, max_d) = (l.out_point - hi, l.in_point - lo);
|
||||
if min_d > max_d {
|
||||
continue;
|
||||
}
|
||||
shifts.push((l.id, delta.max(min_d).min(max_d)));
|
||||
}
|
||||
if shifts.is_empty() {
|
||||
return Err(bad("layer.slip", "select a footage or precomp layer (without time remapping)"));
|
||||
}
|
||||
s.edit("Slip Edit", merge_p(p), |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for (id, d) in &shifts {
|
||||
if let Some(l) = comp.layer_mut(*id) {
|
||||
l.start_time += *d;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let how = str_p(p, "to").unwrap_or("front").to_string();
|
||||
@@ -976,6 +1027,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_layers,
|
||||
timing
|
||||
),
|
||||
cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip),
|
||||
cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| {
|
||||
let mut p = p.clone();
|
||||
p["frames"] = json!(-1);
|
||||
slip(s, &p)
|
||||
}),
|
||||
cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| {
|
||||
let mut p = p.clone();
|
||||
p["frames"] = json!(1);
|
||||
slip(s, &p)
|
||||
}),
|
||||
cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?}", has_layers, arrange),
|
||||
cmd!("layer.precompose", "Pre-compose...", ["Layer"], Some("Cmd+Shift+C"), "{layers?, name?}", has_layers, precompose),
|
||||
cmd!(
|
||||
|
||||
@@ -20,19 +20,38 @@ fn pts(v: Option<&Value>) -> Vec<[f64; 2]> {
|
||||
v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| pt(Some(p))).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Find a mask group by uid, 1-based index or name.
|
||||
fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
let i = match key {
|
||||
/// Index of a mask group by uid, 1-based index or name.
|
||||
fn mask_index(masks: &PropGroup, key: &Value) -> Option<usize> {
|
||||
match key {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64()?;
|
||||
masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))?
|
||||
masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))
|
||||
}
|
||||
Value::String(s) => masks.children.iter().position(|c| c.name() == s)?,
|
||||
_ => return None,
|
||||
};
|
||||
Value::String(s) => masks.children.iter().position(|c| c.name() == s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a mask group by uid, 1-based index or name.
|
||||
fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
let i = mask_index(masks, key)?;
|
||||
masks.children.get_mut(i)?.as_group_mut()
|
||||
}
|
||||
|
||||
/// Whether a group holds an editable Bezier path (a mask, or a shape layer's Path item).
|
||||
pub(crate) fn is_path_group(g: &PropGroup) -> bool {
|
||||
matches!(g.get("path").map(|p| &p.value), Some(KV::Path(_))) && (matches!(g.kind, effectcraft_project::GroupKind::Mask { .. }) || g.match_id == "path")
|
||||
}
|
||||
|
||||
/// A mask (uid, 1-based index or name) or, by uid, a shape layer's Path item.
|
||||
pub(crate) fn path_group<'a>(props: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
if let Some(i) = props.sub("masks").and_then(|m| mask_index(m, key)) {
|
||||
return props.sub_mut("masks")?.children.get_mut(i)?.as_group_mut();
|
||||
}
|
||||
let uid = key.as_u64()?;
|
||||
props.find_group_mut(uid).filter(|g| is_path_group(g))
|
||||
}
|
||||
|
||||
/// Edit the mask path of (layer, mask) at the current time.
|
||||
fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<T>) -> Result<(Uid, T)> {
|
||||
let (cid, lid) = layer_p(s, p, label)?;
|
||||
@@ -41,8 +60,7 @@ fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Sha
|
||||
s.edit(label, merge_p(p), |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let masks = l.props.sub_mut("masks").ok_or_else(|| bad(label, "this layer has no masks"))?;
|
||||
let g = find_mask(masks, &key).ok_or_else(|| bad(label, "no such mask"))?;
|
||||
let g = path_group(&mut l.props, &key).ok_or_else(|| bad(label, "no such mask or shape path"))?;
|
||||
let uid = g.uid;
|
||||
let roto = super::paths::is_roto(g);
|
||||
let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?;
|
||||
@@ -186,7 +204,7 @@ fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapeP
|
||||
for ((l, m), idx) in &groups {
|
||||
let Some(layer) = comp.layer_mut(effectcraft_project::LayerId(*l)) else { continue };
|
||||
let lt = layer.layer_time(t);
|
||||
let Some(g) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)) else { continue };
|
||||
let Some(g) = layer.props.find_group_mut(*m).filter(|g| is_path_group(g)) else { continue };
|
||||
let roto = super::paths::is_roto(g);
|
||||
let Some(pr) = g.get_mut("path") else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
@@ -233,6 +251,93 @@ fn delete_vertices(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Convert Vertex tool: a corner vertex becomes smooth (tangents along its neighbours, a sixth
|
||||
/// of their distance each way), a smooth one becomes a corner (no tangents). `smooth` forces one.
|
||||
fn convert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask.convertVertex", "missing `index`"))? as usize;
|
||||
let force = b_p(p, "smooth");
|
||||
let (_, smooth) = edit_path(s, p, "Convert Vertex", |sp| {
|
||||
let n = sp.vertices.len();
|
||||
if i >= n {
|
||||
return Err(bad("mask.convertVertex", "no such vertex"));
|
||||
}
|
||||
sp.in_tangents.resize(n, [0.0; 2]);
|
||||
sp.out_tangents.resize(n, [0.0; 2]);
|
||||
let is_smooth = sp.in_tangents[i] != [0.0; 2] || sp.out_tangents[i] != [0.0; 2];
|
||||
let make_smooth = force.unwrap_or(!is_smooth);
|
||||
if make_smooth {
|
||||
let prev = if i > 0 {
|
||||
Some(i - 1)
|
||||
} else if sp.closed {
|
||||
Some(n - 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let next = if i + 1 < n {
|
||||
Some(i + 1)
|
||||
} else if sp.closed {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let v = sp.vertices[i];
|
||||
let a = prev.map(|j| sp.vertices[j]).unwrap_or(v);
|
||||
let b = next.map(|j| sp.vertices[j]).unwrap_or(v);
|
||||
let mut d = [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0];
|
||||
if d == [0.0, 0.0] {
|
||||
d = [10.0, 0.0];
|
||||
}
|
||||
sp.out_tangents[i] = d;
|
||||
sp.in_tangents[i] = [-d[0], -d[1]];
|
||||
} else {
|
||||
sp.in_tangents[i] = [0.0; 2];
|
||||
sp.out_tangents[i] = [0.0; 2];
|
||||
}
|
||||
Ok(make_smooth)
|
||||
})?;
|
||||
Ok(json!({"smooth": smooth}))
|
||||
}
|
||||
|
||||
/// Split cubic segment `i` (vertex i → i+1) at parameter `t`; the path keeps its shape.
|
||||
pub(crate) fn split_segment(sp: &mut ShapePath, seg: usize, t: f64) -> Option<usize> {
|
||||
let n = sp.vertices.len();
|
||||
let j = if seg + 1 < n {
|
||||
seg + 1
|
||||
} else if sp.closed && seg + 1 == n {
|
||||
0
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
sp.in_tangents.resize(n, [0.0; 2]);
|
||||
sp.out_tangents.resize(n, [0.0; 2]);
|
||||
let p0 = sp.vertices[seg];
|
||||
let p3 = sp.vertices[j];
|
||||
let p1 = [p0[0] + sp.out_tangents[seg][0], p0[1] + sp.out_tangents[seg][1]];
|
||||
let p2 = [p3[0] + sp.in_tangents[j][0], p3[1] + sp.in_tangents[j][1]];
|
||||
let lerp = |a: [f64; 2], b: [f64; 2]| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
let (a, b, c) = (lerp(p0, p1), lerp(p1, p2), lerp(p2, p3));
|
||||
let (d, e) = (lerp(a, b), lerp(b, c));
|
||||
let m = lerp(d, e);
|
||||
sp.out_tangents[seg] = [a[0] - p0[0], a[1] - p0[1]];
|
||||
sp.in_tangents[j] = [c[0] - p3[0], c[1] - p3[1]];
|
||||
let at = seg + 1;
|
||||
sp.vertices.insert(at, m);
|
||||
sp.in_tangents.insert(at, [d[0] - m[0], d[1] - m[1]]);
|
||||
sp.out_tangents.insert(at, [e[0] - m[0], e[1] - m[1]]);
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// Add Vertex tool: insert a vertex on segment `segment` at parameter `t` (default 0.5).
|
||||
fn insert_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let seg =
|
||||
p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("mask.insertVertex", "missing `segment` (index of the segment's first vertex)"))? as usize;
|
||||
let t = super::f_p(p, "t").unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
let (_, lid) = layer_p(s, p, "mask.insertVertex")?;
|
||||
let (uid, i) = edit_path(s, p, "Add Vertex", |sp| split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment")))?;
|
||||
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
|
||||
Ok(json!(i))
|
||||
}
|
||||
|
||||
fn remove_masks(s: &mut Session, p: &Value, all: bool) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "mask.remove")?;
|
||||
let key = p.get("mask").cloned();
|
||||
@@ -263,6 +368,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices),
|
||||
cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices),
|
||||
cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices),
|
||||
cmd!("mask.convertVertex", "Convert Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), index, smooth?}", has_comp, convert_vertex),
|
||||
cmd!("mask.insertVertex", "Add Vertex", [], None, "{layer?, mask: uid (mask or shape Path item), segment, t?: 0..1}", has_comp, insert_vertex),
|
||||
cmd!("mask.remove", "Remove Mask", [], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)),
|
||||
cmd!("mask.removeAll", "Remove All Masks", [], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)),
|
||||
]
|
||||
|
||||
@@ -11,6 +11,7 @@ mod file;
|
||||
mod file_more;
|
||||
mod frontend;
|
||||
mod help;
|
||||
mod key_transform;
|
||||
mod layer;
|
||||
mod layer_menu;
|
||||
mod layer_time;
|
||||
@@ -32,6 +33,9 @@ mod three_d;
|
||||
mod time;
|
||||
mod track;
|
||||
mod view;
|
||||
pub mod viewer_cmds;
|
||||
#[cfg(test)]
|
||||
pub(crate) use mask::split_segment as split_segment_for_tests;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -108,6 +112,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(layer_menu::specs());
|
||||
v.extend(animation::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(viewer_cmds::specs());
|
||||
v.extend(key_transform::specs());
|
||||
v.extend(file_more::specs());
|
||||
v.extend(lottie::specs());
|
||||
v.extend(comp_more::specs());
|
||||
|
||||
@@ -63,6 +63,13 @@ fn masks_for(s: &Session, p: &Value) -> Result<(ItemId, Vec<(LayerId, Uid)>)> {
|
||||
v.dedup();
|
||||
return Ok((cid, v));
|
||||
}
|
||||
// A shape layer's Path item by uid.
|
||||
if let Some(uid) = p.get("mask").and_then(Value::as_u64) {
|
||||
let (cid, lid) = layer_p(s, p, "path")?;
|
||||
if s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find_group(uid)).is_some_and(|g| g.match_id == "path") {
|
||||
return Ok((cid, vec![(lid, uid)]));
|
||||
}
|
||||
}
|
||||
target_masks(s, p)
|
||||
}
|
||||
|
||||
@@ -230,7 +237,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (lid, uid) in &targets {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let Some(g) = l.props.sub_mut("masks").and_then(|m| m.find_group_mut(*uid)) else { continue };
|
||||
let Some(g) = l.props.find_group_mut(*uid).filter(|g| super::mask::is_path_group(g)) else { continue };
|
||||
let roto = is_roto(g);
|
||||
let Some(pr) = g.get_mut("path") else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
|
||||
@@ -53,7 +53,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
stub!("material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], "{}"),
|
||||
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
|
||||
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
|
||||
stub!("view.res.custom", "Custom...", ["View", "Resolution"], "{}"),
|
||||
// Color management.
|
||||
stub!("view.displayColorManagement", "Use Display Color Management", ["View"], "{}"),
|
||||
stub!("view.simulateOutput", "Simulate Output", [], "{profile}"),
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! 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, Snapshot};
|
||||
use crate::{EngineError, Result, Session, VertexRef, cmd};
|
||||
|
||||
/// 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).
|
||||
#[derive(Clone, Debug, Default, 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,
|
||||
}
|
||||
|
||||
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) };
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping),
|
||||
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
|
||||
),
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user