Merge main into M13.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
7513807972
|
|
@ -1223,6 +1223,7 @@ dependencies = [
|
|||
"effectcraft-text",
|
||||
"effectcraft-time",
|
||||
"effectcraft-track",
|
||||
"exr",
|
||||
"image",
|
||||
"kurbo",
|
||||
"log",
|
||||
|
|
|
|||
|
|
@ -34,3 +34,4 @@ log = { workspace = true }
|
|||
rayon = { workspace = true }
|
||||
image = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
exr = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -395,7 +395,7 @@ fn report(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
/// Orientation (degrees, AE order) of a camera whose +X, +Y (down) and +Z axes point along the
|
||||
/// given world directions: searched over quarter turns, which is all the cube faces need.
|
||||
fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] {
|
||||
pub(crate) fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] {
|
||||
let mut best = ([0.0; 3], f64::MAX);
|
||||
for a in 0..4 {
|
||||
for b in 0..4 {
|
||||
|
|
@ -416,7 +416,7 @@ fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] {
|
|||
|
||||
/// Cube faces in the VR Converter's order (+X, −X, +Y, −Y, +Z, −Z): name, forward, right and up
|
||||
/// in the VR convention (X right, Y up, Z front).
|
||||
const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [
|
||||
pub(crate) const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [
|
||||
("Right", [1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
|
||||
("Left", [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
|
||||
("Top", [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]),
|
||||
|
|
@ -425,10 +425,10 @@ const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [
|
|||
("Back", [0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
|
||||
];
|
||||
/// (column, row) of each face in the 3:2 cube map.
|
||||
const CELLS_3X2: [(u32, u32); 6] = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)];
|
||||
pub(crate) const CELLS_3X2: [(u32, u32); 6] = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)];
|
||||
|
||||
/// VR world direction → After Effects world (Y down).
|
||||
fn ae(v: [f64; 3]) -> Vec3 {
|
||||
pub(crate) fn ae(v: [f64; 3]) -> Vec3 {
|
||||
vec3(v[0], -v[1], v[2])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,14 @@ fn add_property(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let group = p.get("group").and_then(Value::as_u64);
|
||||
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
let rename = str_p(p, "name").map(str::to_string);
|
||||
// `as`: show the property as another control (Source Text as `font`, Scale as `scale`).
|
||||
let as_type = match str_p(p, "as") {
|
||||
None => None,
|
||||
Some(a) => {
|
||||
let t = ControlType::parse(a).ok_or_else(|| bad(C, format!("as: font|scale|text|color|slider|checkbox|point|angle|dropdown, not `{a}`")))?;
|
||||
Some(t)
|
||||
}
|
||||
};
|
||||
let mut added = vec![];
|
||||
for (cid, lid, uid) in &targets {
|
||||
let l = s.project.comp(*cid).and_then(|c| c.layer(*lid)).ok_or(EngineError::NoComp)?;
|
||||
|
|
@ -95,20 +103,32 @@ fn add_property(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if control_type(pr).is_none() {
|
||||
return Err(bad(C, format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name)));
|
||||
}
|
||||
if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for(*lid, *uid).is_some()) {
|
||||
// The natural control needs no `as`.
|
||||
let as_type = as_type.filter(|t| control_type(pr) != Some(*t));
|
||||
if let Some(t) = as_type
|
||||
&& !essential::can_show_as(pr, t)
|
||||
{
|
||||
return Err(bad(C, format!("`{}` can't be shown as a {} control", pr.name, t.label())));
|
||||
}
|
||||
if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for_as(*lid, *uid, as_type).is_some()) {
|
||||
continue;
|
||||
}
|
||||
added.push((*cid, *lid, *uid, rename.clone().unwrap_or_else(|| pr.name.clone())));
|
||||
let name = rename.clone().unwrap_or_else(|| match as_type {
|
||||
Some(ControlType::Font) => format!("{} Font", l.name),
|
||||
_ => pr.name.clone(),
|
||||
});
|
||||
added.push((*cid, *lid, *uid, name, as_type));
|
||||
}
|
||||
let ids = s.edit("Add Property to Essential Graphics", None, |proj, st| {
|
||||
if let Some((cid, ..)) = added.first() {
|
||||
st.essential_primary.get_or_insert(*cid);
|
||||
}
|
||||
let mut ids = vec![];
|
||||
for (k, (cid, lid, uid, name)) in added.iter().enumerate() {
|
||||
for (k, (cid, lid, uid, name, as_type)) in added.iter().enumerate() {
|
||||
let id = proj.alloc();
|
||||
let eg = eg_of(proj, *cid)?;
|
||||
insert(eg, EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid } }, group, index.map(|i| i + k), C)?;
|
||||
let ctl = EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid }, as_type: *as_type };
|
||||
insert(eg, ctl, group, index.map(|i| i + k), C)?;
|
||||
ids.push(id);
|
||||
}
|
||||
Ok(ids)
|
||||
|
|
@ -120,6 +140,39 @@ fn control_type(pr: &effectcraft_project::Property) -> Option<ControlType> {
|
|||
essential::control_type(pr)
|
||||
}
|
||||
|
||||
/// A Font control's value for `prop.set`: `cur` (a Source Text value) with the font family,
|
||||
/// style and size from `v` (`{font?, style?, size?}` or a family name). `None` when `cur` is
|
||||
/// not text.
|
||||
pub fn font_value(cur: &KV, v: &Value) -> Option<Value> {
|
||||
let KV::Text(doc) = cur else { return None };
|
||||
let mut d = (**doc).clone();
|
||||
match v {
|
||||
Value::String(f) => d.font = f.clone(),
|
||||
Value::Object(o) => {
|
||||
if let Some(f) = o.get("font").and_then(Value::as_str) {
|
||||
d.font = f.to_string();
|
||||
}
|
||||
if let Some(st) = o.get("style").and_then(Value::as_str) {
|
||||
d.style = st.to_string();
|
||||
}
|
||||
if let Some(z) = o.get("size").and_then(Value::as_f64) {
|
||||
d.size = z.max(0.1);
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
serde_json::to_value(&d).ok()
|
||||
}
|
||||
|
||||
/// A control's value for agents: Font controls show `{font, style, size}`, the rest the
|
||||
/// property's value.
|
||||
fn value_json(ty: Option<ControlType>, v: &KV) -> Value {
|
||||
match (ty, v) {
|
||||
(Some(ControlType::Font), KV::Text(d)) => json!({"font": d.font, "style": d.style, "size": d.size}),
|
||||
_ => v.to_json(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Media Replacement for a footage layer.
|
||||
fn add_media(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.addMedia";
|
||||
|
|
@ -137,7 +190,7 @@ fn add_media(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if eg.flat().iter().any(|c| matches!(c.kind, EgKind::Media { layer } if layer == lid)) {
|
||||
return Err(bad(C, "this layer already has a Media Replacement control"));
|
||||
}
|
||||
insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid } }, group, None, C)?;
|
||||
insert(eg, EgControl { id, name, kind: EgKind::Media { layer: lid }, as_type: None }, group, None, C)?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
|
|
@ -150,7 +203,7 @@ fn add_group(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let id = s.edit("Add Essential Graphics Group", None, |proj, st| {
|
||||
st.essential_primary.get_or_insert(cid);
|
||||
let id = proj.alloc();
|
||||
insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] } }, None, index, "essential.addGroup")?;
|
||||
insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Group { children: vec![] }, as_type: None }, None, index, "essential.addGroup")?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
|
|
@ -163,7 +216,13 @@ fn add_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let id = s.edit("Add Essential Graphics Comment", None, |proj, st| {
|
||||
st.essential_primary.get_or_insert(cid);
|
||||
let id = proj.alloc();
|
||||
insert(eg_of(proj, cid)?, EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text } }, group, None, "essential.addComment")?;
|
||||
insert(
|
||||
eg_of(proj, cid)?,
|
||||
EgControl { id, name: "Comment".into(), kind: EgKind::Comment { text }, as_type: None },
|
||||
group,
|
||||
None,
|
||||
"essential.addComment",
|
||||
)?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
|
|
@ -262,6 +321,35 @@ fn edit_dropdown(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"prop": uid, "items": items}))
|
||||
}
|
||||
|
||||
/// Composition ▸ Open in Essential Graphics.
|
||||
fn open_in_eg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.state.essential_primary = Some(cid);
|
||||
s.bump();
|
||||
s.events.push(crate::Event::Frontend { command: "window.panel".into(), params: json!({"panel": "essentialGraphics"}) });
|
||||
let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"comp": cid.0, "name": name}))
|
||||
}
|
||||
|
||||
/// Whether a property can be exposed (`property.canAddToMotionGraphicsTemplate`).
|
||||
fn can_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.canAdd";
|
||||
let (cid, lid, uid) = super::prop::prop_ref(s, p, C)?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
let pr = l.props.find(uid).ok_or_else(|| bad(C, "no such property"))?;
|
||||
let as_type = str_p(p, "as").and_then(ControlType::parse);
|
||||
let ty = match as_type {
|
||||
Some(t) if essential::can_show_as(pr, t) => Some(t),
|
||||
Some(t) => return Ok(json!({"ok": false, "reason": format!("`{}` can't be shown as a {} control", pr.name, t.label())})),
|
||||
None => control_type(pr),
|
||||
};
|
||||
let in_eg = super::match_path_of(&l.props, uid).is_some_and(|m| m.starts_with(essential::GROUP));
|
||||
Ok(match ty {
|
||||
Some(t) if !in_eg => json!({"ok": true, "type": t}),
|
||||
_ => json!({"ok": false, "reason": format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name)}),
|
||||
})
|
||||
}
|
||||
|
||||
fn solo_supported(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = b_p(p, "on").unwrap_or(!s.state.essential_solo);
|
||||
s.state.essential_solo = on;
|
||||
|
|
@ -299,8 +387,8 @@ fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value {
|
|||
"id": c.id, "name": c.name, "kind": "property", "layer": layer.0, "prop": prop,
|
||||
"layerName": src.map(|(l, _)| l.name.clone()),
|
||||
"path": src.and_then(|(l, _)| super::match_path_of(&l.props, *prop)),
|
||||
"type": src.and_then(|(_, pr)| essential::control_type(pr)),
|
||||
"value": src.map(|(_, pr)| pr.value_at(Tick::ZERO).to_json()),
|
||||
"type": src.and_then(|(_, pr)| essential::effective_type(c, pr)),
|
||||
"value": src.map(|(_, pr)| value_json(essential::effective_type(c, pr), &pr.value_at(Tick::ZERO))),
|
||||
"missing": src.is_none(),
|
||||
})
|
||||
}
|
||||
|
|
@ -384,8 +472,8 @@ fn instance_json(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"control": c.id, "name": c.name, "kind": kind,
|
||||
"uid": pr.as_ref().map(|x| x.uid),
|
||||
"path": pr.as_ref().and_then(|x| super::match_path_of(&l.props, x.uid)),
|
||||
"value": pr.as_ref().map(|x| x.value_at(l.layer_time(t)).to_json()),
|
||||
"type": pr.as_ref().and_then(essential::control_type).or((kind == "media").then_some(ControlType::Media)),
|
||||
"value": pr.as_ref().map(|x| value_json(essential::effective_type(c, x), &x.value_at(l.layer_time(t)))),
|
||||
"type": pr.as_ref().and_then(|x| essential::effective_type(c, x)).or((kind == "media").then_some(ControlType::Media)),
|
||||
"overridden": pr.as_ref().is_some_and(|x| over.contains(&x.uid)),
|
||||
}));
|
||||
}
|
||||
|
|
@ -395,7 +483,7 @@ fn instance_json(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// Set an instance's value for a control (`value`, or `item` for Media Replacement).
|
||||
fn set_override(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.set";
|
||||
let (cid, lid, _) = instance(s, p, C)?;
|
||||
let (cid, lid, src) = instance(s, p, C)?;
|
||||
let control = control_p(p, C)?;
|
||||
let uid = master_uid(s, cid, lid, control).ok_or_else(|| bad(C, format!("no control {control} on this instance")))?;
|
||||
if let Some(item) = p.get("item") {
|
||||
|
|
@ -414,7 +502,13 @@ fn set_override(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
})?;
|
||||
return Ok(json!({"control": control, "item": item.0}));
|
||||
}
|
||||
let mut q = json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": p.get("value").cloned().ok_or_else(|| bad(C, "missing `value` (or `item`)"))?});
|
||||
let mut value = p.get("value").cloned().ok_or_else(|| bad(C, "missing `value` (or `item`)"))?;
|
||||
let as_type = s.project.comp(src).and_then(|c| c.essential.as_ref()).and_then(|e| e.find(control)).and_then(|c| c.as_type);
|
||||
if as_type == Some(ControlType::Font) {
|
||||
let cur = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid)).map(|pr| pr.value.clone()).unwrap_or(KV::Scalar(0.0));
|
||||
value = font_value(&cur, &value).ok_or_else(|| bad(C, "a Font control takes {font?, style?, size?} or a font family name"))?;
|
||||
}
|
||||
let mut q = json!({"comp": cid.0, "layer": lid.0, "prop": uid, "value": value});
|
||||
if let Some(m) = p.get("merge") {
|
||||
q["merge"] = m.clone();
|
||||
}
|
||||
|
|
@ -477,12 +571,21 @@ fn push_to_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let mut writes = vec![];
|
||||
for (control, uid) in &ts {
|
||||
let (Some(ctl), Some(pr)) = (eg.find(*control), l.props.find(*uid)) else { continue };
|
||||
writes.push((ctl.kind.clone(), pr.clone()));
|
||||
writes.push((ctl.kind.clone(), pr.clone(), ctl.as_type));
|
||||
}
|
||||
s.edit("Push Override Values to Source", None, |proj, _| {
|
||||
let sc = proj.comp_mut(src).ok_or(EngineError::NoComp)?;
|
||||
for (kind, pr) in &writes {
|
||||
for (kind, pr, as_type) in &writes {
|
||||
match kind {
|
||||
// A Font control writes only the font (the source keeps its text and keys).
|
||||
EgKind::Property { layer, prop } if *as_type == Some(ControlType::Font) => {
|
||||
if let Some(target) = sc.layer_mut(*layer).and_then(|l| l.props.find_mut(*prop)) {
|
||||
essential::merge_font(&mut target.value, &pr.value);
|
||||
for k in &mut target.keys {
|
||||
essential::merge_font(&mut k.value, &pr.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
EgKind::Property { layer, prop } => {
|
||||
let Some(inner) = sc.layer_mut(*layer) else { continue };
|
||||
// Instance keys are in the precomp layer's time (= the source comp's time);
|
||||
|
|
@ -740,7 +843,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Add Property to Essential Graphics",
|
||||
["Animation"],
|
||||
None,
|
||||
"{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?}",
|
||||
"{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?, as?: font (Source Text font family/style/size) | scale (uniform Scale)}",
|
||||
has_selected_props,
|
||||
add_property
|
||||
),
|
||||
|
|
@ -774,6 +877,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("essential.revert", "Revert", [], None, "{layer, control? (default: all overridden)}", has_instance, revert),
|
||||
cmd!("effect.editDropdown", "Edit Dropdown Menu", [], None, "{layer, path?|prop?, items: [string]}", has_comp, edit_dropdown),
|
||||
query!("essential.list", "Essential Graphics controls", "{comp?, supported?: bool}", list),
|
||||
query!("essential.canAdd", "Can Add Property to Essential Graphics", "{layer, path|prop, as?} → {ok, type?, reason?}", can_add),
|
||||
cmd!(
|
||||
"comp.openInEssentialGraphics",
|
||||
"Open in Essential Graphics",
|
||||
["Composition"],
|
||||
None,
|
||||
"{comp?} → makes it the Essential Graphics panel's Primary composition and shows the panel",
|
||||
has_comp,
|
||||
open_in_eg
|
||||
),
|
||||
query!("essential.instance", "Essential Properties of a precomp layer", "{layer}", instance_json),
|
||||
query!("essential.templateInfo", "Read a template's manifest", "{path}", template_info),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
//! Composition ▸ Save Frame As ▸ Photoshop Layers… and ProEXR…: the current frame with every
|
||||
//! visible layer kept apart.
|
||||
//!
|
||||
//! - **Photoshop Layers** writes a layered PSD (crates/psd's writer): one pixel layer per comp
|
||||
//! layer, each rendered in isolation (solo, Normal mode, 100% opacity) and cropped to its
|
||||
//! pixels, carrying the layer's blend mode and opacity, plus the flattened frame as the merged
|
||||
//! image. Photoshop (and EffectCraft's PSD import) rebuild the frame from the layers.
|
||||
//! - **ProEXR** writes one multi-layer OpenEXR (half-float-free, 32-bit float, linear light,
|
||||
//! premultiplied): the composite as `R`, `G`, `B`, `A` plus one `<layer name>.R/G/B/A` channel
|
||||
//! set per layer (the layer-prefixed channel convention multi-layer EXR readers use).
|
||||
|
||||
use effectcraft_color::BlendMode;
|
||||
use effectcraft_project::{ItemId, LayerId, Project};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{Backend, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, comp_id, f_p, has_comp, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// One isolated layer of a frame: name, blend mode, opacity (0..1), premultiplied pixels.
|
||||
struct Isolated {
|
||||
name: String,
|
||||
blend: BlendMode,
|
||||
opacity: f64,
|
||||
image: Image,
|
||||
}
|
||||
|
||||
fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
|
||||
let opts = RenderOpts {
|
||||
scale,
|
||||
backend: Backend::Auto,
|
||||
nested_switches: s.prefs.general.switches_affect_nested_comps,
|
||||
draft_shadows: s.prefs.three_d.realtime_shadows,
|
||||
..Default::default()
|
||||
};
|
||||
let mut r = Renderer::new(project, s.footage.as_ref(), opts);
|
||||
r.expr = s.expr.as_deref();
|
||||
r.accel = s.accel.as_deref();
|
||||
r.comp_frame(comp, t)
|
||||
}
|
||||
|
||||
/// The comp frame and each visible layer rendered alone (top layer first).
|
||||
fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Image, Vec<Isolated>)> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let full = render_with(s, &s.project, cid, t, scale);
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.has_video());
|
||||
let ids: Vec<LayerId> = comp
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.has_video() && l.is_active_at(t) && !l.switches.adjustment && !l.switches.guide && !l.environment && (!any_solo || l.switches.solo))
|
||||
.map(|l| l.id)
|
||||
.collect();
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
let mut p: Project = (*s.project).clone();
|
||||
let Some(c) = p.comp_mut(cid) else { continue };
|
||||
let mut info = None;
|
||||
for l in c.layers.iter_mut() {
|
||||
l.switches.solo = l.id == id;
|
||||
if l.id == id {
|
||||
let lt = l.layer_time(t);
|
||||
let opacity = l.props.prop("transform/opacity").map(|o| o.value_at(lt).as_f64() / 100.0).unwrap_or(1.0);
|
||||
info = Some((l.name.clone(), l.blend_mode, opacity.clamp(0.0, 1.0)));
|
||||
l.blend_mode = BlendMode::Normal;
|
||||
if let Some(o) = l.props.prop_mut("transform/opacity") {
|
||||
o.keys.clear();
|
||||
o.expr = None;
|
||||
o.value = effectcraft_keyframe::Value::Scalar(100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((name, blend, opacity)) = info else { continue };
|
||||
let image = render_with(s, &p, cid, t, scale);
|
||||
out.push(Isolated { name, blend, opacity, image });
|
||||
}
|
||||
Ok((full, out))
|
||||
}
|
||||
|
||||
fn straight(p: [f32; 4]) -> [f32; 4] {
|
||||
if p[3] <= 0.0 { [0.0; 4] } else { [p[0] / p[3], p[1] / p[3], p[2] / p[3], p[3].min(1.0)] }
|
||||
}
|
||||
|
||||
/// The Photoshop key of a blend mode (Normal for modes Photoshop lacks).
|
||||
pub fn psd_blend_key(m: BlendMode) -> [u8; 4] {
|
||||
const KEYS: [&[u8; 4]; 26] = [
|
||||
b"diss", b"dark", b"mul ", b"idiv", b"lbrn", b"dkCl", b"lite", b"scrn", b"div ", b"lddg", b"lgCl", b"over", b"sLit", b"hLit", b"vLit", b"lLit",
|
||||
b"pLit", b"hMix", b"diff", b"smud", b"fsub", b"fdiv", b"hue ", b"sat ", b"colr", b"lum ",
|
||||
];
|
||||
KEYS.iter().find(|k| crate::psd_import::blend_mode(std::str::from_utf8(&k[..]).unwrap_or("")) == m).map(|k| **k).unwrap_or(*b"norm")
|
||||
}
|
||||
|
||||
/// The bounding box of non-transparent pixels (`None` when empty).
|
||||
fn alpha_bounds(img: &Image) -> Option<(u32, u32, u32, u32)> {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
if img.data[(y * img.width + x) as usize][3] > 0.5 / 255.0 {
|
||||
x0 = x0.min(x);
|
||||
y0 = y0.min(y);
|
||||
x1 = x1.max(x + 1);
|
||||
y1 = y1.max(y + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
(x1 > x0).then_some((x0, y0, x1, y1))
|
||||
}
|
||||
|
||||
/// The layered PSD bytes of a frame.
|
||||
pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec<u8>, usize, u32, u32)> {
|
||||
use effectcraft_psd::Rect;
|
||||
use effectcraft_psd::write::{WDoc, WLayer, write};
|
||||
let (full, layers) = isolate(s, cid, t, scale)?;
|
||||
let mut doc = WDoc::new(full.width, full.height);
|
||||
let n = layers.len();
|
||||
// File order is bottom first.
|
||||
for l in layers.into_iter().rev() {
|
||||
let mut w = match alpha_bounds(&l.image) {
|
||||
Some((x0, y0, x1, y1)) => {
|
||||
let mut px = Vec::with_capacity(((x1 - x0) * (y1 - y0)) as usize);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
px.push(straight(l.image.data[(y * l.image.width + x) as usize]));
|
||||
}
|
||||
}
|
||||
WLayer::pixels(&l.name, Rect::new(x0 as i32, y0 as i32, (x1 - x0) as i32, (y1 - y0) as i32), px)
|
||||
}
|
||||
None => WLayer::pixels(&l.name, Rect::default(), vec![]),
|
||||
};
|
||||
w.blend = psd_blend_key(l.blend);
|
||||
w.opacity = (l.opacity * 255.0).round() as u8;
|
||||
doc.layers.push(w);
|
||||
}
|
||||
doc.composite = Some(full.data.iter().map(|p| straight(*p)).collect());
|
||||
Ok((write(&doc), n, full.width, full.height))
|
||||
}
|
||||
|
||||
fn srgb_to_linear(c: f32) -> f32 {
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
/// A unique EXR layer name (no dots: they separate the layer from the channel).
|
||||
fn exr_name(name: &str, used: &mut Vec<String>) -> String {
|
||||
let base: String = name.chars().map(|c| if c == '.' || c.is_control() { '_' } else { c }).collect();
|
||||
let base = if base.trim().is_empty() { "Layer".to_string() } else { base };
|
||||
let mut n = base.clone();
|
||||
let mut k = 2;
|
||||
while used.contains(&n) {
|
||||
n = format!("{base} {k}");
|
||||
k += 1;
|
||||
}
|
||||
used.push(n.clone());
|
||||
n
|
||||
}
|
||||
|
||||
/// The multi-layer EXR bytes of a frame and the channel names.
|
||||
pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec<u8>, Vec<String>, u32, u32)> {
|
||||
use exr::prelude::*;
|
||||
let (full, layers) = isolate(s, cid, t, scale)?;
|
||||
let (w, h) = (full.width as usize, full.height as usize);
|
||||
let planes = |img: &effectcraft_raster::Image| -> [Vec<f32>; 4] {
|
||||
let mut out: [Vec<f32>; 4] = Default::default();
|
||||
for p in &img.data {
|
||||
let sp = straight(*p);
|
||||
let a = sp[3];
|
||||
for c in 0..3 {
|
||||
out[c].push(srgb_to_linear(sp[c].clamp(0.0, 1.0)) * a);
|
||||
}
|
||||
out[3].push(a);
|
||||
}
|
||||
out
|
||||
};
|
||||
let mut channels = vec![];
|
||||
let mut names = vec![];
|
||||
let mut push = |prefix: &str, img: &effectcraft_raster::Image| {
|
||||
for (c, data) in ["R", "G", "B", "A"].iter().zip(planes(img)) {
|
||||
let n = if prefix.is_empty() { c.to_string() } else { format!("{prefix}.{c}") };
|
||||
names.push(n.clone());
|
||||
channels.push(AnyChannel::new(n.as_str(), FlatSamples::F32(data)));
|
||||
}
|
||||
};
|
||||
push("", &full);
|
||||
let mut used = vec![];
|
||||
for l in &layers {
|
||||
let n = exr_name(&l.name, &mut used);
|
||||
push(&n, &l.image);
|
||||
}
|
||||
let layer = Layer::new((w, h), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, AnyChannels::sort(channels.into()));
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
Image::from_layer(layer).write().to_buffered(&mut buf).map_err(|e| EngineError::Other(format!("EXR encoding failed: {e}")))?;
|
||||
Ok((buf.into_inner(), names, full.width, full.height))
|
||||
}
|
||||
|
||||
fn frame_args(s: &Session, p: &Value, cmd: &str, ext: &str) -> Result<(String, ItemId, Tick, f64)> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(cmd, format!("missing `path` (.{ext})")))?.to_string();
|
||||
let cid = comp_id(s, p)?;
|
||||
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
|
||||
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
|
||||
Ok((path, cid, t, scale))
|
||||
}
|
||||
|
||||
fn save_psd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (path, cid, t, scale) = frame_args(s, p, "comp.saveFrameAsPsd", "psd")?;
|
||||
let (bytes, n, w, h) = layered_psd(s, cid, t, scale)?;
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.toast(format!("Saved {n} layers to {path}"));
|
||||
Ok(json!({"path": path, "layers": n, "width": w, "height": h, "time": t.seconds()}))
|
||||
}
|
||||
|
||||
fn save_exr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (path, cid, t, scale) = frame_args(s, p, "comp.saveFrameAsExr", "exr")?;
|
||||
let (bytes, channels, w, h) = layered_exr(s, cid, t, scale)?;
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.toast(format!("Saved a multi-layer EXR to {path}"));
|
||||
Ok(json!({"path": path, "channels": channels, "width": w, "height": h, "time": t.seconds()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"comp.saveFrameAsPsd",
|
||||
"Photoshop Layers...",
|
||||
["Composition", "Save Frame As"],
|
||||
None,
|
||||
"{path (.psd), comp?, time?, scale?} → a layered PSD: one layer per visible comp layer (blend mode, opacity) + the merged frame",
|
||||
has_comp,
|
||||
save_psd
|
||||
),
|
||||
cmd!(
|
||||
"comp.saveFrameAsExr",
|
||||
"ProEXR...",
|
||||
["Composition", "Save Frame As"],
|
||||
None,
|
||||
"{path (.exr), comp?, time?, scale?} → a multi-layer OpenEXR: composite R,G,B,A + `<layer>.R/G/B/A` per layer (linear, premultiplied)",
|
||||
has_comp,
|
||||
save_exr
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Show Panel",
|
||||
[],
|
||||
None,
|
||||
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill}",
|
||||
"{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill|createNullsFromPaths|vrCompEditor}",
|
||||
always
|
||||
),
|
||||
fe!(
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("help.website", "ArtCraft Website", ["Help"], None, "{}", always, |s, _| open(s, links::WEBSITE.into())),
|
||||
cmd!("help.appPage", "EffectCraft Home Page", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())),
|
||||
cmd!("help.github", "EffectCraft on GitHub", ["Help"], None, "{}", always, |s, _| open(s, links::GITHUB.into())),
|
||||
cmd!("help.onlineTutorials", "Online Tutorials...", ["Help"], None, "{}", always, |s, _| open(s, links::APP_PAGE.into())),
|
||||
cmd!("help.inAppTutorials", "In-App Tutorials...", ["Help"], None, "{} → the Home screen's Learn tab", always, |s, p| super::frontend(
|
||||
s,
|
||||
"help.inAppTutorials",
|
||||
p
|
||||
)),
|
||||
cmd!("help.reportIssue", "Provide Feedback...", ["Help"], None, "{}", always, |s, _| open(s, links::ISSUES.into())),
|
||||
cmd!(
|
||||
"help.sibling",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ mod align_data;
|
|||
mod anim;
|
||||
pub mod anim_tools;
|
||||
mod animation;
|
||||
mod app_more;
|
||||
pub(crate) mod app_more;
|
||||
mod autotrace;
|
||||
mod camera_cmds;
|
||||
mod comp;
|
||||
|
|
@ -21,6 +21,7 @@ mod file;
|
|||
pub(crate) mod file_more;
|
||||
mod focus;
|
||||
pub mod footage_panel;
|
||||
pub(crate) mod frame_export;
|
||||
mod frontend;
|
||||
mod help;
|
||||
mod key_labels;
|
||||
|
|
@ -29,7 +30,7 @@ mod keys_more;
|
|||
mod layer;
|
||||
mod layer_menu;
|
||||
mod layer_time;
|
||||
mod link;
|
||||
pub(crate) mod link;
|
||||
mod liquify;
|
||||
mod lottie;
|
||||
mod markers;
|
||||
|
|
@ -38,6 +39,7 @@ pub mod mask_interp;
|
|||
pub(crate) mod model3d;
|
||||
pub mod paint;
|
||||
pub mod panels_cmds;
|
||||
pub(crate) mod path_nulls;
|
||||
mod paths;
|
||||
mod project_items;
|
||||
mod prop;
|
||||
|
|
@ -61,7 +63,10 @@ pub mod time;
|
|||
mod track;
|
||||
mod view;
|
||||
pub mod viewer_cmds;
|
||||
pub(crate) mod vpe;
|
||||
pub(crate) mod vr_editor;
|
||||
mod warp_cmds;
|
||||
pub(crate) mod watch_folder;
|
||||
#[cfg(test)]
|
||||
pub(crate) use app_more::report_text as report_text_for_tests;
|
||||
pub(crate) use app_more::view_command;
|
||||
|
|
@ -162,6 +167,11 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(file_more::specs());
|
||||
v.extend(lottie::specs());
|
||||
v.extend(comp_more::specs());
|
||||
v.extend(frame_export::specs());
|
||||
v.extend(watch_folder::specs());
|
||||
v.extend(vpe::specs());
|
||||
v.extend(path_nulls::specs());
|
||||
v.extend(vr_editor::specs());
|
||||
v.extend(frontend::specs());
|
||||
v.extend(track::specs());
|
||||
v.extend(mask_interp::specs());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
//! Window ▸ Create Nulls From Paths: our own panel (and these commands) linking a mask or shape
|
||||
//! path to null layers with expressions, in three ways:
|
||||
//!
|
||||
//! - **Points Follow Nulls** (`paths.pointsFollowNulls`): one null per vertex, placed on it; the
|
||||
//! path's expression rebuilds it from the nulls (`createPath` of each null's position brought
|
||||
//! into the path's layer with `fromComp`), so moving a null moves its vertex.
|
||||
//! - **Nulls Follow Points** (`paths.nullsFollowPoints`): one null per vertex whose Position
|
||||
//! expression follows the vertex (`toComp(path.points()[i])`), for attaching things to an
|
||||
//! animated path.
|
||||
//! - **Trace Path** (`paths.tracePath`): one null with a Progress slider (keyframed 0 → 100 % over
|
||||
//! the layer's span, optionally looping) whose Position follows `pointOnPath(progress)`.
|
||||
//!
|
||||
//! The path is `path`/`prop` of `layer`, else the selected path property, else the layer's first
|
||||
//! mask or shape path. Each command is one undo step.
|
||||
|
||||
use effectcraft_geom::vec2;
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{ItemId, LayerId, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, comp_id, has_layers};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn quote(s: &str) -> String {
|
||||
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
|
||||
}
|
||||
|
||||
/// The target path property: (comp, layer, uid).
|
||||
fn target(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
|
||||
if p.get("path").is_some() || p.get("prop").is_some() {
|
||||
let r = super::prop::prop_ref(s, p, cmd)?;
|
||||
let is_path = s.project.comp(r.0).and_then(|c| c.layer(r.1)).and_then(|l| l.props.find(r.2)).is_some_and(|pr| matches!(pr.value, KV::Path(_)));
|
||||
return if is_path { Ok(r) } else { Err(bad(cmd, "that property is not a mask or shape path")) };
|
||||
}
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
// A selected path property.
|
||||
for (l, u) in super::selected_leaf_props(s) {
|
||||
if comp.layer(l).and_then(|ly| ly.props.find(u)).is_some_and(|pr| matches!(pr.value, KV::Path(_))) {
|
||||
return Ok((cid, l, u));
|
||||
}
|
||||
}
|
||||
// The first path of `layer` (or of the first selected layer).
|
||||
let lid = match p.get("layer") {
|
||||
Some(_) => super::layer_p(s, p, cmd)?.1,
|
||||
None => *s.state.selected_layers.first().ok_or_else(|| bad(cmd, "select a layer with a mask or shape path"))?,
|
||||
};
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let mut found = None;
|
||||
layer.props.walk("", &mut |_, pr| {
|
||||
if found.is_none() && matches!(pr.value, KV::Path(_)) && !matches!(pr.ui, effectcraft_project::ParamUi::Hidden) {
|
||||
found = Some(pr.uid);
|
||||
}
|
||||
});
|
||||
found.map(|u| (cid, lid, u)).ok_or_else(|| bad(cmd, format!("`{}` has no mask or shape path", layer.name)))
|
||||
}
|
||||
|
||||
/// What the commands need about the path: its vertices in comp space (now), the path value, the
|
||||
/// expression reference to it and its layer's name.
|
||||
struct PathInfo {
|
||||
comp_pts: Vec<[f64; 2]>,
|
||||
path: effectcraft_keyframe::ShapePath,
|
||||
reference: String,
|
||||
layer_name: String,
|
||||
prop_name: String,
|
||||
span: (f64, f64),
|
||||
}
|
||||
|
||||
fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result<PathInfo> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let pr = layer.props.find(uid).ok_or_else(|| bad(cmd, "no such property"))?;
|
||||
let t = s.time();
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let KV::Path(path) = ctx.value(layer, pr) else { return Err(bad(cmd, "that property is not a path")) };
|
||||
if path.vertices.is_empty() {
|
||||
return Err(bad(cmd, "the path has no vertices"));
|
||||
}
|
||||
let m = ctx.layer_to_comp(layer).0;
|
||||
let comp_pts = path
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let q = m.apply(vec2(v[0], v[1]));
|
||||
[q.x, q.y]
|
||||
})
|
||||
.collect();
|
||||
// Seen from another layer (the nulls).
|
||||
let mut other = layer.clone();
|
||||
other.id = LayerId(u64::MAX);
|
||||
other.name = "\u{0}".into();
|
||||
let reference = super::link::reference(comp, &other, layer, uid, true).ok_or_else(|| bad(cmd, "can't reference that path"))?;
|
||||
let prop_name = layer.props.name_path_of(uid).and_then(|n| n.split('/').rev().nth(1).map(str::to_string)).unwrap_or_else(|| pr.name.clone());
|
||||
Ok(PathInfo { comp_pts, path, reference, layer_name: layer.name.clone(), prop_name, span: (layer.in_point.seconds(), layer.out_point.seconds()) })
|
||||
}
|
||||
|
||||
/// A new null at `at` (comp space) named `name`, made unique in the comp (expressions find
|
||||
/// layers by name). Returns (layer id, name).
|
||||
fn new_null(s: &mut Session, cid: ItemId, name: &str, at: [f64; 2]) -> Result<(u64, String)> {
|
||||
let taken = |n: &str| s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.name == n));
|
||||
let mut name = name.to_string();
|
||||
let base = name.clone();
|
||||
let mut k = 2;
|
||||
while taken(&name) {
|
||||
name = format!("{base} {k}");
|
||||
k += 1;
|
||||
}
|
||||
let r = s.execute("layer.newNull", json!({"comp": cid.0, "name": name}))?;
|
||||
let id = r.get("layer").and_then(Value::as_u64).ok_or_else(|| EngineError::Other("no null layer".into()))?;
|
||||
s.execute("prop.set", json!({"comp": cid.0, "layer": id, "path": "transform/position", "value": at}))?;
|
||||
Ok((id, name))
|
||||
}
|
||||
|
||||
fn nulls_follow_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paths.nullsFollowPoints";
|
||||
let (cid, lid, uid) = target(s, p, C)?;
|
||||
let pi = info(s, cid, lid, uid, C)?;
|
||||
let nulls = super::app_more::grouped(s, "Nulls Follow Points", |s| {
|
||||
let mut out = vec![];
|
||||
for (i, at) in pi.comp_pts.iter().enumerate() {
|
||||
let (id, _) = new_null(s, cid, &format!("{}: {} [{}]", pi.layer_name, pi.prop_name, i + 1), *at)?;
|
||||
let e = format!("var src = thisComp.layer({});\nvar p = src.toComp({}.points()[{i}]);\n[p[0], p[1]]", quote(&pi.layer_name), pi.reference);
|
||||
s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "transform/position", "expression": e}))?;
|
||||
out.push(id);
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({"nulls": nulls, "layer": lid.0, "prop": uid}))
|
||||
}
|
||||
|
||||
fn points_follow_nulls(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paths.pointsFollowNulls";
|
||||
let (cid, lid, uid) = target(s, p, C)?;
|
||||
let pi = info(s, cid, lid, uid, C)?;
|
||||
let nulls = super::app_more::grouped(s, "Points Follow Nulls", |s| {
|
||||
let mut out = vec![];
|
||||
let mut names = vec![];
|
||||
for (i, at) in pi.comp_pts.iter().enumerate() {
|
||||
let name = format!("{}: {} [{}]", pi.layer_name, pi.prop_name, i + 1);
|
||||
let (id, name) = new_null(s, cid, &name, *at)?;
|
||||
out.push(id);
|
||||
names.push(quote(&name));
|
||||
}
|
||||
let arr = |v: &[[f64; 2]]| serde_json::to_string(v).unwrap_or_else(|_| "[]".into());
|
||||
let e = format!(
|
||||
"var nulls = [{}];\nvar pts = [];\nfor (var i = 0; i < nulls.length; i++) {{\n var n = thisComp.layer(nulls[i]);\n var q = fromComp(n.toComp(n.transform.anchorPoint));\n pts.push([q[0], q[1]]);\n}}\ncreatePath(pts, {}, {}, {})",
|
||||
names.join(", "),
|
||||
arr(&pi.path.in_tangents),
|
||||
arr(&pi.path.out_tangents),
|
||||
pi.path.closed
|
||||
);
|
||||
s.execute("prop.setExpression", json!({"comp": cid.0, "layer": lid.0, "prop": uid, "expression": e}))?;
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({"nulls": nulls, "layer": lid.0, "prop": uid}))
|
||||
}
|
||||
|
||||
fn trace_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paths.tracePath";
|
||||
let (cid, lid, uid) = target(s, p, C)?;
|
||||
let pi = info(s, cid, lid, uid, C)?;
|
||||
let looping = b_p(p, "loop").unwrap_or(false);
|
||||
let nid = super::app_more::grouped(s, "Trace Path", |s| {
|
||||
let (id, _) = new_null(s, cid, &format!("Trace {}: {}", pi.layer_name, pi.prop_name), pi.comp_pts[0])?;
|
||||
s.execute("effect.apply", json!({"comp": cid.0, "layers": [id], "effect": "ec.control.slider"}))?;
|
||||
let fx = s.project.comp(cid).and_then(|c| c.layer(LayerId(id))).and_then(|l| l.props.group("effects/#1")).map(|g| g.uid);
|
||||
if let Some(fx) = fx {
|
||||
s.execute("prop.renameGroup", json!({"comp": cid.0, "layer": id, "prop": fx, "name": "Progress"}))?;
|
||||
}
|
||||
let (a, b) = pi.span;
|
||||
s.execute("prop.addKey", json!({"comp": cid.0, "layer": id, "path": "effects/#1/slider", "time": a, "value": 0.0}))?;
|
||||
s.execute("prop.addKey", json!({"comp": cid.0, "layer": id, "path": "effects/#1/slider", "time": b.max(a), "value": 100.0}))?;
|
||||
if looping {
|
||||
s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "effects/#1/slider", "expression": "loopOut(\"cycle\")"}))?;
|
||||
}
|
||||
let e = format!(
|
||||
"var src = thisComp.layer({});\nvar p = src.toComp({}.pointOnPath(effect(\"Progress\")(\"Slider\") / 100));\n[p[0], p[1]]",
|
||||
quote(&pi.layer_name),
|
||||
pi.reference
|
||||
);
|
||||
s.execute("prop.setExpression", json!({"comp": cid.0, "layer": id, "path": "transform/position", "expression": e}))?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"null": nid, "layer": lid.0, "prop": uid}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"paths.pointsFollowNulls",
|
||||
"Points Follow Nulls",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path?|prop?} → a null per vertex; the path follows them (expression)",
|
||||
has_layers,
|
||||
points_follow_nulls
|
||||
),
|
||||
cmd!(
|
||||
"paths.nullsFollowPoints",
|
||||
"Nulls Follow Points",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path?|prop?} → a null per vertex following it (Position expressions)",
|
||||
has_layers,
|
||||
nulls_follow_points
|
||||
),
|
||||
cmd!(
|
||||
"paths.tracePath",
|
||||
"Trace Path",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path?|prop?, loop?: bool} → a null moving along the path (Progress slider)",
|
||||
has_layers,
|
||||
trace_path
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -188,7 +188,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
crate::query!(
|
||||
"scriptui.get",
|
||||
"Script Window Controls",
|
||||
"{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, children…}}",
|
||||
"{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, draw (onDraw paint list), children…}}",
|
||||
scriptui::get
|
||||
),
|
||||
cmd!(
|
||||
|
|
@ -205,7 +205,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Set Script Window Control",
|
||||
[],
|
||||
None,
|
||||
"{window?, widget, value: text | number | bool | item index | item text} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange",
|
||||
"{window?, widget, value: text | number | bool | item index | item text, changing?: bool (a live update: each keystroke / slider step; fires onChanging only)} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange",
|
||||
always,
|
||||
scriptui::set
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
//! File ▸ Import ▸ Vanishing Point (.vpe): present in the menu like After Effects', but always
|
||||
//! disabled. The `.vpe` exchange format Photoshop's Vanishing Point filter writes has no public
|
||||
//! specification, and EffectCraft implements formats only from published specs (clean room), so
|
||||
//! the entry explains that instead of importing. See docs/parity.md.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{CommandSpec, bad};
|
||||
use crate::{Result, Session, cmd};
|
||||
|
||||
/// Why the entry is disabled (shown as its tooltip).
|
||||
pub const WHY: &str =
|
||||
"Vanishing Point Exchange (.vpe) files can't be imported: the format has no public specification, and EffectCraft only implements documented formats";
|
||||
|
||||
fn import(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(bad("file.importVanishingPoint", WHY))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"file.importVanishingPoint",
|
||||
"Vanishing Point (.vpe)...",
|
||||
["File", "Import"],
|
||||
None,
|
||||
"{} (always disabled: undocumented format)",
|
||||
|_| Err(WHY.into()),
|
||||
import
|
||||
)]
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
//! Window ▸ VR Comp Editor: our own panel over the VR environments Composition ▸ VR ▸ Create VR
|
||||
//! Environment builds (an equirectangular output comp ← a 3:2 cube map ← six face comps, each
|
||||
//! with a 90° camera). It lists the environments and edits the 360 view's camera orientation:
|
||||
//! the six face cameras turn together, so the whole equirectangular output rotates (pan / tilt /
|
||||
//! roll the viewer's starting direction) without touching the scene.
|
||||
//!
|
||||
//! The view orientation is not stored separately: it is read back from the Front camera
|
||||
//! (`camera = view × face base`), so editing a camera by hand shows up here too.
|
||||
|
||||
use effectcraft_geom::{Mat4, vec3};
|
||||
use effectcraft_project::{GroupKind, ItemId, LayerId, LayerSource, Project};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::app_more::{CELLS_3X2, FACES, ae, grouped, orientation_for_axes};
|
||||
use super::{CommandSpec, always, bad};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
/// A VR environment: its output comp, cube map and the face cameras in [`FACES`] order.
|
||||
pub struct VrEnv {
|
||||
pub output: ItemId,
|
||||
pub cube: ItemId,
|
||||
pub faces: [(ItemId, LayerId); 6],
|
||||
}
|
||||
|
||||
/// The VR environments of a project.
|
||||
pub fn environments(p: &Project) -> Vec<VrEnv> {
|
||||
let mut out = vec![];
|
||||
for (oid, oc) in p.comps() {
|
||||
for l in &oc.layers {
|
||||
let LayerSource::Comp { item: cube } = l.source else { continue };
|
||||
let vr = l.effects().is_some_and(|fx| fx.groups().any(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.vr.converter")));
|
||||
if !vr {
|
||||
continue;
|
||||
}
|
||||
let Some(cc) = p.comp(cube) else { continue };
|
||||
let size = (cc.width / 3).max(1) as f64;
|
||||
let mut faces: [Option<(ItemId, LayerId)>; 6] = [None; 6];
|
||||
for fl in &cc.layers {
|
||||
let LayerSource::Comp { item: face } = fl.source else { continue };
|
||||
let pos = fl.props.prop("transform/position").map(|x| x.value.components()).unwrap_or_default();
|
||||
let (Some(x), Some(y)) = (pos.first(), pos.get(1)) else { continue };
|
||||
let cell = ((x / size).floor().max(0.0) as u32, (y / size).floor().max(0.0) as u32);
|
||||
let Some(i) = CELLS_3X2.iter().position(|c| *c == cell) else { continue };
|
||||
let cam = p.comp(face).and_then(|fc| fc.layers.iter().find(|x| x.is_camera())).map(|x| x.id);
|
||||
if let Some(cam) = cam {
|
||||
faces[i] = Some((face, cam));
|
||||
}
|
||||
}
|
||||
if faces.iter().all(Option::is_some) {
|
||||
out.push(VrEnv { output: *oid, cube, faces: faces.map(|f| f.unwrap_or((ItemId(0), LayerId(0)))) });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn base(i: usize) -> [f64; 3] {
|
||||
let (_, fw, r, u) = FACES[i];
|
||||
orientation_for_axes(ae(r), ae([-u[0], -u[1], -u[2]]), ae(fw))
|
||||
}
|
||||
|
||||
fn orient(o: [f64; 3]) -> Mat4 {
|
||||
Mat4::orientation(vec3(o[0], o[1], o[2]))
|
||||
}
|
||||
|
||||
fn transpose(m: Mat4) -> Mat4 {
|
||||
let mut t = Mat4::IDENTITY;
|
||||
for r in 0..3 {
|
||||
for c in 0..3 {
|
||||
t.0[r][c] = m.0[c][r];
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
fn norm(a: f64) -> f64 {
|
||||
let r = a.rem_euclid(360.0);
|
||||
let r = if (r - 360.0).abs() < 1e-7 { 0.0 } else { r };
|
||||
(r * 1e6).round() / 1e6
|
||||
}
|
||||
|
||||
/// Orientation angles (X, Y, Z degrees, applied X then Y then Z) of a rotation matrix.
|
||||
pub fn euler(m: Mat4) -> [f64; 3] {
|
||||
let m = m.0;
|
||||
let y = (-m[2][0]).clamp(-1.0, 1.0).asin();
|
||||
let (x, z) = if y.cos().abs() > 1e-6 { (m[2][1].atan2(m[2][2]), m[1][0].atan2(m[0][0])) } else { ((-m[1][2]).atan2(m[1][1]), 0.0) };
|
||||
[norm(x.to_degrees()), norm(y.to_degrees()), norm(z.to_degrees())]
|
||||
}
|
||||
|
||||
/// The current view orientation of an environment (from its Front camera).
|
||||
fn view_of(p: &Project, env: &VrEnv) -> [f64; 3] {
|
||||
let (fc, cam) = env.faces[4];
|
||||
let o = p.comp(fc).and_then(|c| c.layer(cam)).and_then(|l| l.props.prop("transform/orientation")).map(|x| x.value.components()).unwrap_or_default();
|
||||
let o = [o.first().copied().unwrap_or(0.0), o.get(1).copied().unwrap_or(0.0), o.get(2).copied().unwrap_or(0.0)];
|
||||
euler(orient(o) * transpose(orient(base(4))))
|
||||
}
|
||||
|
||||
fn env_json(p: &Project, e: &VrEnv) -> Value {
|
||||
let name = |i: ItemId| p.item(i).map(|x| x.name.clone()).unwrap_or_default();
|
||||
json!({
|
||||
"output": e.output.0, "name": name(e.output), "cubeMap": e.cube.0,
|
||||
"faces": e.faces.iter().enumerate().map(|(i, (c, cam))| json!({"face": FACES[i].0, "comp": c.0, "camera": cam.0})).collect::<Vec<_>>(),
|
||||
"view": view_of(p, e),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!(environments(&s.project).iter().map(|e| env_json(&s.project, e)).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
/// The environment `comp` (its output, cube map or a face comp), else the active comp's, else
|
||||
/// the only one.
|
||||
fn env_p(s: &Session, p: &Value, cmd: &str) -> Result<VrEnv> {
|
||||
let envs = environments(&s.project);
|
||||
let want = p.get("comp").and_then(Value::as_u64).map(ItemId).or(s.state.active_comp);
|
||||
let hit = |e: &VrEnv, c: ItemId| e.output == c || e.cube == c || e.faces.iter().any(|f| f.0 == c);
|
||||
let i = want.and_then(|c| envs.iter().position(|e| hit(e, c))).or((envs.len() == 1).then_some(0));
|
||||
let i = i.ok_or_else(|| {
|
||||
bad(cmd, if envs.is_empty() { "no VR environment (Composition ▸ VR ▸ Create VR Environment…)" } else { "give `comp`: a VR environment's output comp" })
|
||||
})?;
|
||||
envs.into_iter().nth(i).ok_or(EngineError::NoComp)
|
||||
}
|
||||
|
||||
fn set_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "comp.vr.setView";
|
||||
let env = env_p(s, p, C)?;
|
||||
let mut o = view_of(&s.project, &env);
|
||||
match p.get("orientation") {
|
||||
Some(Value::Array(a)) => {
|
||||
for (k, v) in a.iter().take(3).enumerate() {
|
||||
o[k] = v.as_f64().ok_or_else(|| bad(C, "orientation: [x, y, z] degrees"))?;
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(bad(C, "orientation: [x, y, z] degrees")),
|
||||
None => {}
|
||||
}
|
||||
// Pan (Y), tilt (X) and roll (Z) by name.
|
||||
for (k, i) in [("tilt", 0), ("pan", 1), ("roll", 2)] {
|
||||
if let Some(v) = p.get(k).and_then(Value::as_f64) {
|
||||
o[i] = v;
|
||||
}
|
||||
}
|
||||
let v = orient(o);
|
||||
grouped(s, "VR View Orientation", |s| {
|
||||
for (i, (fc, cam)) in env.faces.iter().enumerate() {
|
||||
let e = euler(v * orient(base(i)));
|
||||
s.execute("prop.set", json!({"comp": fc.0, "layer": cam.0, "path": "transform/orientation", "value": e}))?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"output": env.output.0, "view": view_of(&s.project, &env)}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!("comp.vr.environments", "VR Environments", "{} → [{output, name, cubeMap, faces: [{face, comp, camera}], view: [x, y, z]}]", list),
|
||||
cmd!(
|
||||
"comp.vr.setView",
|
||||
"VR View Orientation",
|
||||
[],
|
||||
None,
|
||||
"{comp? (an environment's output / cube map / face comp), orientation?: [x, y, z] degrees, pan?, tilt?, roll?} → turns the six face cameras together",
|
||||
always,
|
||||
set_view
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
//! File ▸ Watch Folder…: render farm without a farm. EffectCraft watches a folder for project
|
||||
//! files (`.ecproj`, at the top level or one folder down, e.g. the folders File ▸ Dependencies ▸
|
||||
//! Collect Files writes) whose Render Queue has queued items, renders them, and writes a status
|
||||
//! file next to each project (`<project>.status.json`: `rendering`, then `done` or `failed`, with
|
||||
//! every item's status and output path). A project with a status file is not picked up again;
|
||||
//! delete the status file to render it once more.
|
||||
//!
|
||||
//! [`poll`] does one pass and is what the frontends call on a timer while a folder is watched
|
||||
//! (`file.watchFolder.poll`); it is headless and synchronous, so tests and the CLI use it
|
||||
//! directly.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use effectcraft_project::render_queue::RenderStatus;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, str_p};
|
||||
use crate::{Result, Session, cmd};
|
||||
|
||||
/// The status file of a project.
|
||||
pub fn status_path(project: &Path) -> PathBuf {
|
||||
let mut s = project.as_os_str().to_owned();
|
||||
s.push(".status.json");
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Projects in `folder` (and its direct subfolders) without a status file, sorted.
|
||||
pub fn pending(folder: &Path) -> std::io::Result<Vec<PathBuf>> {
|
||||
let mut out = vec![];
|
||||
let scan = |dir: &Path, out: &mut Vec<PathBuf>, subdirs: &mut Vec<PathBuf>| -> std::io::Result<()> {
|
||||
for e in std::fs::read_dir(dir)? {
|
||||
let p = e?.path();
|
||||
if p.is_dir() {
|
||||
subdirs.push(p);
|
||||
} else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("ecproj")) && !status_path(&p).exists() {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let mut subdirs = vec![];
|
||||
scan(folder, &mut out, &mut subdirs)?;
|
||||
for d in subdirs {
|
||||
let mut ignore = vec![];
|
||||
let _ = scan(&d, &mut out, &mut ignore);
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn write_status(path: &Path, v: &Value) {
|
||||
let _ = std::fs::write(status_path(path), serde_json::to_vec_pretty(v).unwrap_or_default());
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Render one project (in its own session sharing `host`'s services) and write its status.
|
||||
fn render_project(host: &Session, path: &Path) -> Value {
|
||||
let name = path.to_string_lossy().to_string();
|
||||
write_status(path, &json!({"project": name, "state": "rendering", "started": now()}));
|
||||
let mut s = Session { services: host.services.clone(), footage: host.footage.clone(), expr: host.expr.clone(), ..Default::default() };
|
||||
s.importer = host.importer.clone();
|
||||
s.exporter = host.exporter.clone();
|
||||
s.accel = host.accel.clone();
|
||||
s.prefs = host.prefs.clone();
|
||||
let result = (|| -> std::result::Result<Value, String> {
|
||||
s.execute("file.open", json!({"path": name})).map_err(|e| e.to_string())?;
|
||||
let queued = s.project.render_queue.iter().filter(|i| i.render && i.status == RenderStatus::Queued).count();
|
||||
if queued == 0 {
|
||||
return Ok(json!({"state": "done", "items": [], "note": "nothing queued"}));
|
||||
}
|
||||
s.start_render(true)?;
|
||||
let items: Vec<Value> = s
|
||||
.project
|
||||
.render_queue
|
||||
.iter()
|
||||
.map(|i| {
|
||||
json!({
|
||||
"comp": s.project.item(i.comp).map(|c| c.name.clone()),
|
||||
"status": i.status.label(),
|
||||
"error": if let RenderStatus::Failed(e) = &i.status { Some(e.clone()) } else { None },
|
||||
"output": i.last_output,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let failed = s.project.render_queue.iter().any(|i| matches!(i.status, RenderStatus::Failed(_)));
|
||||
Ok(json!({"state": if failed { "failed" } else { "done" }, "items": items}))
|
||||
})();
|
||||
let mut v = match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => json!({"state": "failed", "error": e, "items": []}),
|
||||
};
|
||||
v["project"] = json!(name);
|
||||
v["finished"] = json!(now());
|
||||
write_status(path, &v);
|
||||
v
|
||||
}
|
||||
|
||||
/// One pass over `folder`: render every new project; returns their statuses.
|
||||
pub fn poll(s: &mut Session, folder: &str) -> std::result::Result<Vec<Value>, String> {
|
||||
let projects = pending(Path::new(folder)).map_err(|e| format!("cannot read {folder}: {e}"))?;
|
||||
Ok(projects.iter().map(|p| render_project(s, p)).collect())
|
||||
}
|
||||
|
||||
fn watch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.watchFolder";
|
||||
if p.get("stop").and_then(Value::as_bool) == Some(true) {
|
||||
s.state.watch_folder = None;
|
||||
s.bump();
|
||||
return Ok(json!({"watching": null}));
|
||||
}
|
||||
let folder = str_p(p, "folder").or(str_p(p, "path")).ok_or_else(|| bad(C, "missing `folder`"))?.to_string();
|
||||
if !Path::new(&folder).is_dir() {
|
||||
return Err(bad(C, format!("{folder} is not a folder")));
|
||||
}
|
||||
s.state.watch_folder = Some(folder.clone());
|
||||
s.bump();
|
||||
// The first pass right away.
|
||||
let done = poll(s, &folder).map_err(|e| bad(C, e))?;
|
||||
s.toast(format!("Watching {folder}"));
|
||||
Ok(json!({"watching": folder, "rendered": done}))
|
||||
}
|
||||
|
||||
fn poll_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.watchFolder.poll";
|
||||
let folder = str_p(p, "folder").map(str::to_string).or_else(|| s.state.watch_folder.clone()).ok_or_else(|| bad(C, "no folder is being watched"))?;
|
||||
let done = poll(s, &folder).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"watching": folder, "rendered": done}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"file.watchFolder",
|
||||
"Watch Folder...",
|
||||
["File"],
|
||||
None,
|
||||
"{folder, stop?: bool} → watches the folder for .ecproj files with queued renders, renders them and writes `<project>.status.json`",
|
||||
always,
|
||||
watch
|
||||
),
|
||||
cmd!(
|
||||
"file.watchFolder.poll",
|
||||
"Poll Watch Folder",
|
||||
[],
|
||||
None,
|
||||
"{folder? (default: the watched one)} → renders new projects now: [{project, state: done|failed, items: [{comp, status, output}]}]",
|
||||
|s: &Session| if s.state.watch_folder.is_some() { Ok(()) } else { Err("no folder is being watched".into()) },
|
||||
poll_cmd
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -219,6 +219,10 @@ pub struct EditorState {
|
|||
/// Essential Graphics can expose).
|
||||
#[serde(default)]
|
||||
pub essential_solo: bool,
|
||||
/// File ▸ Watch Folder: the folder being watched for projects to render (frontends call
|
||||
/// `file.watchFolder.poll` periodically while it is set).
|
||||
#[serde(default)]
|
||||
pub watch_folder: Option<String>,
|
||||
/// The Footage panel: the footage shown, its time and In/Out marks (source time).
|
||||
#[serde(default)]
|
||||
pub footage_panel: Option<commands::footage_panel::FootageView>,
|
||||
|
|
@ -786,12 +790,18 @@ mod tests_effects;
|
|||
#[cfg(test)]
|
||||
mod tests_essential;
|
||||
#[cfg(test)]
|
||||
mod tests_essential_more;
|
||||
#[cfg(test)]
|
||||
mod tests_face;
|
||||
#[cfg(test)]
|
||||
mod tests_fidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_frame_export;
|
||||
#[cfg(test)]
|
||||
mod tests_lottie;
|
||||
#[cfg(test)]
|
||||
mod tests_m137;
|
||||
#[cfg(test)]
|
||||
mod tests_m145;
|
||||
#[cfg(test)]
|
||||
mod tests_markers;
|
||||
|
|
|
|||
|
|
@ -505,6 +505,7 @@ File
|
|||
Placeholder... | file.importPlaceholder
|
||||
Solid... | file.importSolid
|
||||
Lottie... | file.importLottie
|
||||
Vanishing Point (.vpe)... | file.importVanishingPoint
|
||||
Essential Graphics Template... | essential.importTemplate
|
||||
Import Recent Footage
|
||||
@dynamic:recentFootage
|
||||
|
|
@ -528,6 +529,7 @@ File
|
|||
Find Missing Effects | file.findMissing {"what":"effects"}
|
||||
Find Missing Fonts | file.findMissing {"what":"fonts"}
|
||||
Find Missing Footage | file.findMissing {"what":"footage"}
|
||||
Watch Folder... | file.watchFolder
|
||||
---
|
||||
Scripts
|
||||
@dynamic:scripts
|
||||
|
|
@ -664,9 +666,12 @@ Composition
|
|||
Audio | playback.audio
|
||||
Save Frame As
|
||||
File... | comp.saveFrameAs
|
||||
Photoshop Layers... | comp.saveFrameAsPsd
|
||||
ProEXR... | comp.saveFrameAsExr
|
||||
Pre-render... | render.preRender
|
||||
Save Current Preview... | render.saveCurrentPreview
|
||||
---
|
||||
Open in Essential Graphics | comp.openInEssentialGraphics
|
||||
Responsive Design — Time
|
||||
Create Intro | comp.responsiveTime {"op":"intro"}
|
||||
Create Outro | comp.responsiveTime {"op":"outro"}
|
||||
|
|
@ -1165,6 +1170,8 @@ Window
|
|||
Timeline | window.panel {"panel":"timeline"}
|
||||
@dynamic:openViewers
|
||||
---
|
||||
Create Nulls From Paths | window.panel {"panel":"createNullsFromPaths"}
|
||||
VR Comp Editor | window.panel {"panel":"vrCompEditor"}
|
||||
@dynamic:scriptPanels
|
||||
Help
|
||||
EffectCraft Help... | help.docs {"page":"help"} | F1
|
||||
|
|
@ -1174,6 +1181,9 @@ Help
|
|||
Animation Presets... | anim.browsePresets
|
||||
Keyboard Shortcuts... | app.keyboardShortcuts
|
||||
---
|
||||
In-App Tutorials... | help.inAppTutorials
|
||||
Online Tutorials... | help.onlineTutorials
|
||||
---
|
||||
System Compatibility Report... | help.systemReport
|
||||
Enable Logging | help.enableLogging
|
||||
Reveal Logging File | help.revealLogFile
|
||||
|
|
|
|||
|
|
@ -112,9 +112,103 @@ pub struct Widget {
|
|||
pub handlers: Vec<String>,
|
||||
/// The active tab of a tabbed panel (child index).
|
||||
pub active_tab: usize,
|
||||
/// What the control's `onDraw` handler painted (ScriptUIGraphics), in control coordinates.
|
||||
/// Frontends paint it instead of the control's default look, unless it contains
|
||||
/// [`DrawOp::Os`] (`drawOSControl()`), which draws the default look first.
|
||||
pub draw: Vec<DrawOp>,
|
||||
pub children: Vec<Widget>,
|
||||
}
|
||||
|
||||
/// One segment of a ScriptUIGraphics path (control coordinates, pixels).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "k")]
|
||||
pub enum PathSeg {
|
||||
/// `moveTo(x, y)`: starts a new subpath.
|
||||
M { x: f64, y: f64 },
|
||||
/// `lineTo(x, y)`.
|
||||
L { x: f64, y: f64 },
|
||||
/// `rectPath(x, y, w, h)`: a closed rectangle subpath.
|
||||
R { x: f64, y: f64, w: f64, h: f64 },
|
||||
/// `ellipsePath(x, y, w, h)`: a closed ellipse inscribed in the rectangle.
|
||||
E { x: f64, y: f64, w: f64, h: f64 },
|
||||
/// `closePath()`.
|
||||
Z,
|
||||
}
|
||||
|
||||
/// One paint call of an `onDraw` handler. Colours are straight RGBA in 0..1; `None` is a theme
|
||||
/// colour (a brush or pen made from a theme colour name).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "camelCase")]
|
||||
pub enum DrawOp {
|
||||
/// `fillPath(brush, path)`.
|
||||
Fill { color: Option<[f32; 4]>, path: Vec<PathSeg> },
|
||||
/// `strokePath(pen, path)`.
|
||||
Stroke { color: Option<[f32; 4]>, width: f64, path: Vec<PathSeg> },
|
||||
/// `drawString(text, pen, x, y, font)`: `(x, y)` is the text's top-left.
|
||||
Text {
|
||||
text: String,
|
||||
color: Option<[f32; 4]>,
|
||||
x: f64,
|
||||
y: f64,
|
||||
size: f64,
|
||||
#[serde(default)]
|
||||
style: String,
|
||||
},
|
||||
/// `drawOSControl()`: the control's default look.
|
||||
Os,
|
||||
/// `drawImage(image, x, y, w?, h?)` (drawn as a placeholder frame).
|
||||
Image { x: f64, y: f64, w: f64, h: f64 },
|
||||
}
|
||||
|
||||
impl PathSeg {
|
||||
/// The subpaths of `path` as polylines (ellipses flattened to `n` segments), each with
|
||||
/// whether it is closed.
|
||||
pub fn polylines(path: &[PathSeg], n: usize) -> Vec<(Vec<[f64; 2]>, bool)> {
|
||||
fn flush(cur: &mut Vec<[f64; 2]>, out: &mut Vec<(Vec<[f64; 2]>, bool)>, closed: bool) {
|
||||
if cur.len() > 1 {
|
||||
out.push((std::mem::take(cur), closed));
|
||||
} else {
|
||||
cur.clear();
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
let mut cur: Vec<[f64; 2]> = vec![];
|
||||
for seg in path {
|
||||
match *seg {
|
||||
PathSeg::M { x, y } => {
|
||||
flush(&mut cur, &mut out, false);
|
||||
cur.push([x, y]);
|
||||
}
|
||||
PathSeg::L { x, y } => cur.push([x, y]),
|
||||
PathSeg::Z => {
|
||||
let start = cur.first().copied();
|
||||
flush(&mut cur, &mut out, true);
|
||||
// Drawing continues from the subpath's start.
|
||||
cur.extend(start);
|
||||
}
|
||||
PathSeg::R { x, y, w, h } => {
|
||||
flush(&mut cur, &mut out, false);
|
||||
out.push((vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]], true));
|
||||
}
|
||||
PathSeg::E { x, y, w, h } => {
|
||||
flush(&mut cur, &mut out, false);
|
||||
let (cx, cy, rx, ry) = (x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
let n = n.max(8);
|
||||
let pts = (0..n)
|
||||
.map(|i| {
|
||||
let a = i as f64 / n as f64 * std::f64::consts::TAU;
|
||||
[cx + rx * a.cos(), cy + ry * a.sin()]
|
||||
})
|
||||
.collect();
|
||||
out.push((pts, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut cur, &mut out, false);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget {
|
||||
pub fn find(&self, id: u32) -> Option<&Widget> {
|
||||
if self.id == id {
|
||||
|
|
@ -493,7 +587,8 @@ pub(crate) fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let i = w.items.iter().position(|it| it == t).ok_or_else(|| crate::commands::bad(c, format!("no item `{t}` (items: {:?})", w.items)))?;
|
||||
value = json!(i);
|
||||
}
|
||||
dispatch(s, ScriptUiEvent { window, widget, kind: "change".into(), value })
|
||||
let kind = if p.get("changing").and_then(Value::as_bool) == Some(true) { "changing" } else { "change" };
|
||||
dispatch(s, ScriptUiEvent { window, widget, kind: kind.into(), value })
|
||||
}
|
||||
|
||||
/// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel).
|
||||
|
|
@ -557,6 +652,31 @@ mod tests {
|
|||
assert_eq!(win.root.lookup(&json!(1)).unwrap().id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_lists_flatten_to_polylines_and_round_trip() {
|
||||
let path = vec![
|
||||
PathSeg::M { x: 0.0, y: 0.0 },
|
||||
PathSeg::L { x: 10.0, y: 0.0 },
|
||||
PathSeg::L { x: 10.0, y: 10.0 },
|
||||
PathSeg::Z,
|
||||
PathSeg::R { x: 1.0, y: 2.0, w: 3.0, h: 4.0 },
|
||||
PathSeg::E { x: 0.0, y: 0.0, w: 20.0, h: 10.0 },
|
||||
];
|
||||
let lines = PathSeg::polylines(&path, 16);
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[0], (vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]], true));
|
||||
assert_eq!(lines[1].0[2], [4.0, 6.0]);
|
||||
assert_eq!(lines[2].0.len(), 16);
|
||||
assert!((lines[2].0[0][0] - 20.0).abs() < 1e-9 && (lines[2].0[4][1] - 10.0).abs() < 1e-9);
|
||||
let op = DrawOp::Fill { color: Some([1.0, 0.0, 0.0, 1.0]), path };
|
||||
let j = serde_json::to_value(&op).unwrap();
|
||||
assert_eq!(j["op"], "fill");
|
||||
assert_eq!(j["path"][0], json!({"k": "M", "x": 0.0, "y": 0.0}));
|
||||
assert_eq!(serde_json::from_value::<DrawOp>(j).unwrap(), op);
|
||||
let t: DrawOp = serde_json::from_value(json!({"op": "text", "text": "Hi", "color": null, "x": 1, "y": 2, "size": 14})).unwrap();
|
||||
assert!(matches!(t, DrawOp::Text { size, .. } if size == 14.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_replaces_a_hosts_windows() {
|
||||
let mut ui = ScriptUi::default();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
//! Essential Graphics Font and uniform Scale controls, Composition ▸ Open in Essential Graphics.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::essential::{self, ControlType};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Event, Session};
|
||||
|
||||
#[test]
|
||||
fn font_and_scale_controls_override_per_instance() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let title = s.active_comp_id().unwrap();
|
||||
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
|
||||
// Source Text: as text and as font; Scale as one slider.
|
||||
let t = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
let f = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
let sc = s.execute("essential.addProperty", json!({"layer": text, "path": "transform/scale", "as": "scale"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
// Adding the same font control again is a no-op; opacity can't be a font.
|
||||
assert!(
|
||||
s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"].as_array().unwrap().is_empty()
|
||||
);
|
||||
assert!(s.execute("essential.addProperty", json!({"layer": text, "path": "transform/opacity", "as": "font"})).is_err());
|
||||
assert_eq!(s.execute("essential.canAdd", json!({"layer": text, "path": "transform/opacity", "as": "font"})).unwrap()["ok"], false);
|
||||
assert_eq!(s.execute("essential.canAdd", json!({"layer": text, "path": "transform/scale", "as": "scale"})).unwrap()["type"], "scale");
|
||||
let l = s.execute("essential.list", json!({})).unwrap();
|
||||
let types: Vec<&str> = l["controls"].as_array().unwrap().iter().map(|c| c["type"].as_str().unwrap()).collect();
|
||||
assert_eq!(types, ["text", "font", "scale"]);
|
||||
assert!(l["controls"][1]["value"]["size"].as_f64().unwrap() > 0.0);
|
||||
let eg = s.project.comp(title).unwrap().essential.clone().unwrap();
|
||||
assert_eq!(eg.find(f).unwrap().as_type, Some(ControlType::Font));
|
||||
// Survives save / load (serde).
|
||||
let j = serde_json::to_value(&eg).unwrap();
|
||||
assert_eq!(serde_json::from_value::<essential::EssentialGraphics>(j).unwrap(), eg);
|
||||
|
||||
// An instance: change the text, then the font and the scale.
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [inst]})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": inst, "control": t, "value": "Bye"})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": inst, "control": f, "value": {"font": "Serif Test", "size": 40}})).unwrap();
|
||||
s.execute("essential.set", json!({"layer": inst, "control": sc, "value": 50})).unwrap();
|
||||
let i = s.execute("essential.instance", json!({"layer": inst})).unwrap();
|
||||
assert_eq!(i["controls"][1]["value"]["font"], "Serif Test");
|
||||
assert_eq!(i["controls"][1]["value"]["size"], 40.0);
|
||||
assert_eq!(i["controls"][1]["overridden"], true);
|
||||
assert_eq!(i["controls"][2]["type"], "scale");
|
||||
assert_eq!(i["controls"][2]["value"][0], 50.0);
|
||||
assert_eq!(i["controls"][2]["value"][1], 50.0);
|
||||
// What the instance renders: the overridden text with the overridden font, scaled.
|
||||
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(inst)).unwrap().clone();
|
||||
let over: Vec<essential::Override> = {
|
||||
let g = essential::group(&layer).unwrap();
|
||||
let mut v = vec![];
|
||||
g.walk("", &mut |_, p| {
|
||||
if let Some(c) = essential::control_of(&p.match_id) {
|
||||
v.push(essential::Override { control: c, value: p.value.clone() });
|
||||
}
|
||||
});
|
||||
v
|
||||
};
|
||||
let p = essential::with_overrides(&s.project, title, &over).unwrap();
|
||||
let src = p.comp(title).unwrap().layer(effectcraft_project::LayerId(text)).unwrap();
|
||||
match src.props.prop("text/sourceText").map(|x| &x.value) {
|
||||
Some(KV::Text(d)) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)),
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
// Push to Comp with only the font: the source keeps its own text.
|
||||
s.execute("essential.revert", json!({"layer": inst, "control": t})).unwrap();
|
||||
s.execute("essential.pushToComp", json!({"layer": inst, "control": f})).unwrap();
|
||||
let srcl = s.project.comp(title).unwrap().layer(effectcraft_project::LayerId(text)).unwrap();
|
||||
match srcl.props.prop("text/sourceText").map(|x| &x.value) {
|
||||
Some(KV::Text(d)) => assert_eq!((d.text.as_str(), d.font.as_str()), ("Hello", "Serif Test")),
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_in_essential_graphics_sets_the_primary_comp() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "A", "width": 32, "height": 32, "duration": 1})).unwrap();
|
||||
let a = s.active_comp_id().unwrap();
|
||||
s.execute("comp.new", json!({"name": "B", "width": 32, "height": 32, "duration": 1})).unwrap();
|
||||
s.drain_events();
|
||||
let r = s.execute("comp.openInEssentialGraphics", json!({"comp": a.0})).unwrap();
|
||||
assert_eq!(r["name"], "A");
|
||||
assert_eq!(s.state.essential_primary, Some(a));
|
||||
assert!(
|
||||
s.drain_events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, Event::Frontend { command, params } if command == "window.panel" && params["panel"] == "essentialGraphics"))
|
||||
);
|
||||
assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A");
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
//! Composition ▸ Save Frame As ▸ Photoshop Layers… / ProEXR….
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn out_dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A 32×32 comp: a red full-frame solid under a 16×16 blue Multiply solid at 50%.
|
||||
fn frame() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Frame", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000"})).unwrap();
|
||||
let blue = s.execute("layer.newSolid", json!({"name": "Blue.Box", "color": "#0000ff", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setBlendMode", json!({"layers": [blue], "mode": "Multiply"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": blue, "path": "transform/opacity", "value": 50})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photoshop_layers_writes_one_psd_layer_per_comp_layer() {
|
||||
let mut s = frame();
|
||||
let dir = out_dir("frame-psd");
|
||||
let path = dir.join("frame.psd").to_string_lossy().to_string();
|
||||
let r = s.execute("comp.saveFrameAsPsd", json!({"path": path})).unwrap();
|
||||
assert_eq!(r["layers"], 2);
|
||||
let psd = effectcraft_psd::Psd::parse(std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!((psd.width, psd.height), (32, 32));
|
||||
let names: Vec<&str> = psd.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, ["Red", "Blue.Box"], "bottom first");
|
||||
let blue = &psd.layers[1];
|
||||
assert_eq!(blue.blend_key(), "mul ");
|
||||
assert_eq!(blue.opacity, 128);
|
||||
// Cropped to its pixels (16×16, centred) and rendered at full opacity.
|
||||
assert_eq!((blue.rect.left, blue.rect.top, blue.rect.width(), blue.rect.height()), (8, 8, 16, 16));
|
||||
assert_eq!(psd.layers[0].blend_key(), "norm");
|
||||
assert_eq!(psd.layers[0].opacity, 255);
|
||||
// The merged image is the comp frame: red × (50% blue multiply) in the middle.
|
||||
let m = psd.composite().unwrap();
|
||||
let mid = m.data[16 * 32 + 16];
|
||||
assert!(mid[0] > 0.4 && mid[0] < 0.6 && mid[2] < 0.05, "{mid:?}");
|
||||
let corner = m.data[0];
|
||||
assert!(corner[0] > 0.99 && corner[2] < 0.01, "{corner:?}");
|
||||
// The menu has it.
|
||||
assert!(crate::commands::find("comp.saveFrameAsPsd").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proexr_writes_layer_prefixed_channels_and_the_composite() {
|
||||
use exr::prelude::*;
|
||||
let mut s = frame();
|
||||
let dir = out_dir("frame-exr");
|
||||
let path = dir.join("frame.exr").to_string_lossy().to_string();
|
||||
let r = s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
|
||||
let names: Vec<&str> = r["channels"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect();
|
||||
assert_eq!(names.len(), 12);
|
||||
assert!(names.contains(&"R") && names.contains(&"A") && names.contains(&"Red.R") && names.contains(&"Blue_Box.B"), "{names:?}");
|
||||
let img = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_file(&path).unwrap();
|
||||
let layer = &img.layer_data[0];
|
||||
assert_eq!((layer.size.0, layer.size.1), (32, 32));
|
||||
let ch = |n: &str| layer.channel_data.list.iter().find(|c| c.name.to_string() == n).unwrap_or_else(|| panic!("no channel {n}"));
|
||||
let at = |n: &str, x: usize, y: usize| match &ch(n).sample_data {
|
||||
FlatSamples::F32(v) => v[y * 32 + x],
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
// The blue layer alone: blue inside its box, transparent outside.
|
||||
assert!((at("Blue_Box.B", 16, 16) - 1.0).abs() < 1e-3);
|
||||
assert_eq!(at("Blue_Box.A", 0, 0), 0.0);
|
||||
assert!((at("Red.R", 0, 0) - 1.0).abs() < 1e-3);
|
||||
// Composite: opaque everywhere.
|
||||
assert!((at("A", 16, 16) - 1.0).abs() < 1e-3);
|
||||
assert!(at("R", 16, 16) < 0.5, "multiplied by 50% blue (linear)");
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
//! M13.7 menu items: File ▸ Watch Folder, Import ▸ Vanishing Point (disabled), Create Nulls From
|
||||
//! Paths, VR Comp Editor and Help ▸ In-App / Online Tutorials.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::rq_tests::MockExporter;
|
||||
use crate::{Event, Session};
|
||||
|
||||
fn out_dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_folder_renders_queued_projects_and_writes_status() {
|
||||
let dir = out_dir("watch-folder");
|
||||
let ex = Arc::new(MockExporter { log: Default::default() });
|
||||
// A project with a queued render, and one (in a subfolder) with nothing queued.
|
||||
let mut a = Session { exporter: Some(ex.clone()), ..Default::default() };
|
||||
a.execute("comp.new", json!({"name": "Shot", "width": 32, "height": 32, "frameRate": 10, "duration": 0.5})).unwrap();
|
||||
a.execute("renderQueue.add", json!({})).unwrap();
|
||||
let pa = dir.join("a.ecproj");
|
||||
a.execute("file.saveAs", json!({"path": pa.to_string_lossy()})).unwrap();
|
||||
std::fs::create_dir_all(dir.join("collected")).unwrap();
|
||||
let mut b = Session::default();
|
||||
b.execute("comp.new", json!({"name": "Idle", "width": 32, "height": 32, "duration": 1})).unwrap();
|
||||
let pb = dir.join("collected/b.ecproj");
|
||||
b.execute("file.saveAs", json!({"path": pb.to_string_lossy()})).unwrap();
|
||||
|
||||
let mut host = Session { exporter: Some(ex.clone()), ..Default::default() };
|
||||
assert!(!host.is_enabled("file.watchFolder.poll"));
|
||||
let r = host.execute("file.watchFolder", json!({"folder": dir.to_string_lossy()})).unwrap();
|
||||
let done = r["rendered"].as_array().unwrap();
|
||||
assert_eq!(done.len(), 2, "{r}");
|
||||
assert_eq!(host.state.watch_folder.as_deref(), Some(dir.to_string_lossy().as_ref()));
|
||||
let st: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("a.ecproj.status.json")).unwrap()).unwrap();
|
||||
assert_eq!(st["state"], "done", "{st}");
|
||||
assert_eq!(st["items"][0]["comp"], "Shot");
|
||||
assert_eq!(st["items"][0]["status"], "Done");
|
||||
assert_eq!(ex.log.lock().unwrap().len(), 1);
|
||||
let st: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("collected/b.ecproj.status.json")).unwrap()).unwrap();
|
||||
assert_eq!(st["note"], "nothing queued");
|
||||
// Rendered projects are not picked up again; deleting the status file re-renders.
|
||||
assert!(host.execute("file.watchFolder.poll", json!({})).unwrap()["rendered"].as_array().unwrap().is_empty());
|
||||
std::fs::remove_file(dir.join("a.ecproj.status.json")).unwrap();
|
||||
assert_eq!(host.execute("file.watchFolder.poll", json!({})).unwrap()["rendered"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(ex.log.lock().unwrap().len(), 2);
|
||||
// A broken project fails with a status, not an error.
|
||||
std::fs::write(dir.join("broken.ecproj"), b"not a project").unwrap();
|
||||
let r = host.execute("file.watchFolder.poll", json!({})).unwrap();
|
||||
assert_eq!(r["rendered"][0]["state"], "failed");
|
||||
host.execute("file.watchFolder", json!({"stop": true})).unwrap();
|
||||
assert!(host.state.watch_folder.is_none());
|
||||
assert!(host.execute("file.watchFolder", json!({"folder": "/no/such/folder"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanishing_point_import_is_disabled_with_a_reason() {
|
||||
let mut s = Session::default();
|
||||
assert!(!s.is_enabled("file.importVanishingPoint"));
|
||||
let spec = crate::commands::find("file.importVanishingPoint").unwrap();
|
||||
assert!((spec.enabled)(&s).unwrap_err().contains("public specification"));
|
||||
assert!(s.execute("file.importVanishingPoint", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_tutorials() {
|
||||
let mut s = Session::default();
|
||||
s.drain_events();
|
||||
s.execute("help.onlineTutorials", json!({})).unwrap();
|
||||
s.execute("help.inAppTutorials", json!({})).unwrap();
|
||||
let ev = s.drain_events();
|
||||
assert!(ev.iter().any(|e| matches!(e, Event::OpenUrl(u) if u == crate::links::APP_PAGE)));
|
||||
assert!(ev.iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == "help.inAppTutorials")));
|
||||
}
|
||||
|
||||
/// A comp with a 2D solid at (50, 40) carrying a square mask.
|
||||
fn masked() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Paths", "width": 100, "height": 80, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true})).unwrap();
|
||||
(s, l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nulls_follow_points_and_points_follow_nulls() {
|
||||
let (mut s, l) = masked();
|
||||
s.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||||
let r = s.execute("paths.nullsFollowPoints", json!({})).unwrap();
|
||||
let nulls: Vec<u64> = r["nulls"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
|
||||
assert_eq!(nulls.len(), 4);
|
||||
let comp = s.active_comp().unwrap().clone();
|
||||
let n2 = comp.layer(effectcraft_project::LayerId(nulls[2])).unwrap();
|
||||
assert_eq!(n2.name, "Plate: Mask 1 [3]");
|
||||
// Placed on the vertex in comp space (the 40×40 solid is centred at (50, 40)).
|
||||
let pos = n2.props.prop("transform/position").unwrap();
|
||||
assert_eq!(&pos.value.components()[..2], &[70.0, 60.0]);
|
||||
let e = pos.expr.as_ref().unwrap().text.clone();
|
||||
assert!(e.contains("thisComp.layer(\"Plate\")") && e.contains(".points()[2]"), "{e}");
|
||||
// One undo step.
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
|
||||
let r = s.execute("paths.pointsFollowNulls", json!({"layer": l, "path": "masks/#1/path"})).unwrap();
|
||||
assert_eq!(r["nulls"].as_array().unwrap().len(), 4);
|
||||
let comp = s.active_comp().unwrap().clone();
|
||||
let plate = comp.layer(effectcraft_project::LayerId(l)).unwrap();
|
||||
let e = plate.props.prop("masks/#1/path").unwrap().expr.as_ref().unwrap().text.clone();
|
||||
assert!(e.contains("createPath(pts") && e.contains("\"Plate: Mask 1 [1]\"") && e.contains("fromComp"), "{e}");
|
||||
// Not a path → error.
|
||||
assert!(s.execute("paths.nullsFollowPoints", json!({"layer": l, "path": "transform/opacity"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_path_adds_a_progress_null() {
|
||||
let (mut s, l) = masked();
|
||||
let r = s.execute("paths.tracePath", json!({"layer": l, "loop": true})).unwrap();
|
||||
let n = r["null"].as_u64().unwrap();
|
||||
let comp = s.active_comp().unwrap().clone();
|
||||
let null = comp.layer(effectcraft_project::LayerId(n)).unwrap();
|
||||
let slider = null.props.prop("effects/#1/slider").unwrap();
|
||||
assert_eq!(slider.keys.len(), 2);
|
||||
assert_eq!(slider.keys[1].value.as_f64(), 100.0);
|
||||
assert!(slider.expr.as_ref().unwrap().text.contains("loopOut"));
|
||||
assert_eq!(null.props.group("effects/#1").unwrap().name, "Progress");
|
||||
assert!(null.props.prop("transform/position").unwrap().expr.as_ref().unwrap().text.contains("pointOnPath(effect(\"Progress\")(\"Slider\") / 100)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vr_comp_editor_turns_the_face_cameras_together() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Scene", "width": 64, "height": 64, "frameRate": 10, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Wall", "color": "#ff0000"})).unwrap();
|
||||
s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap();
|
||||
let envs = s.execute("comp.vr.environments", json!({})).unwrap();
|
||||
assert_eq!(envs.as_array().unwrap().len(), 1, "{envs}");
|
||||
let env = &envs[0];
|
||||
assert_eq!(env["name"], "Scene VR Output");
|
||||
assert_eq!(env["faces"].as_array().unwrap().len(), 6);
|
||||
let view = env["view"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
|
||||
assert!(view.iter().all(|v| v.abs() < 1e-6 || (v - 360.0).abs() < 1e-6), "{view:?}");
|
||||
let out = env["output"].as_u64().unwrap();
|
||||
let r = s.execute("comp.vr.setView", json!({"comp": out, "pan": 30.0})).unwrap();
|
||||
let v = r["view"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
|
||||
assert!((v[1] - 30.0).abs() < 1e-6 && v[0].abs() < 1e-6 && v[2].abs() < 1e-6, "{v:?}");
|
||||
// Every face camera turned: Front by 30° about Y.
|
||||
let front = &env["faces"][4];
|
||||
let fc = s.project.comp(effectcraft_project::ItemId(front["comp"].as_u64().unwrap())).unwrap();
|
||||
let cam = fc.layer(effectcraft_project::LayerId(front["camera"].as_u64().unwrap())).unwrap();
|
||||
let o = cam.props.prop("transform/orientation").unwrap().value.components();
|
||||
assert!((o[1] - 30.0).abs() < 1e-6, "{o:?}");
|
||||
// One undo step back to the start.
|
||||
assert!(s.undo());
|
||||
let envs = s.execute("comp.vr.environments", json!({})).unwrap();
|
||||
assert!(envs[0]["view"][1].as_f64().unwrap().abs() < 1e-6);
|
||||
// Round trip of the Euler decomposition.
|
||||
for o in [[10.0, 20.0, 30.0], [350.0, 45.0, 5.0], [0.0, 0.0, 270.0]] {
|
||||
let m = effectcraft_geom::Mat4::orientation(effectcraft_geom::vec3(o[0], o[1], o[2]));
|
||||
let e = crate::commands::vr_editor::euler(m);
|
||||
assert!(e.iter().zip(o).all(|(a, b)| (a - b).abs() < 1e-6), "{o:?} → {e:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -351,6 +351,52 @@ __Path.prototype.points = function () { return this.__pts; };
|
|||
__Path.prototype.inTangents = function () { return this.__in; };
|
||||
__Path.prototype.outTangents = function () { return this.__out; };
|
||||
__Path.prototype.isClosed = function () { return this.__closed; };
|
||||
// pointOnPath / tangentOnPath: a point (or unit tangent) at a fraction of the path's arc length.
|
||||
__Path.prototype.pointOnPath = function (pct) { return __pathAt(this, pct === undefined ? 0.5 : pct, false); };
|
||||
__Path.prototype.tangentOnPath = function (pct) { return __pathAt(this, pct === undefined ? 0.5 : pct, true); };
|
||||
__Path.prototype.normalOnPath = function (pct) { var t = this.tangentOnPath(pct); return [t[1], -t[0]]; };
|
||||
|
||||
function __pathSegs(p) {
|
||||
var n = p.__pts.length, out = [];
|
||||
var m = p.__closed ? n : n - 1;
|
||||
for (var i = 0; i < m; i++) {
|
||||
var j = (i + 1) % n, a = p.__pts[i], b = p.__pts[j];
|
||||
var o = p.__out[i] || [0, 0], ii = p.__in[j] || [0, 0];
|
||||
out.push([a, [a[0] + o[0], a[1] + o[1]], [b[0] + ii[0], b[1] + ii[1]], b]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function __bezAt(s, u) {
|
||||
var v = 1 - u;
|
||||
return [0, 1].map(function (k) { return v * v * v * s[0][k] + 3 * v * v * u * s[1][k] + 3 * v * u * u * s[2][k] + u * u * u * s[3][k]; });
|
||||
}
|
||||
function __bezDer(s, u) {
|
||||
var v = 1 - u;
|
||||
return [0, 1].map(function (k) { return 3 * v * v * (s[1][k] - s[0][k]) + 6 * v * u * (s[2][k] - s[1][k]) + 3 * u * u * (s[3][k] - s[2][k]); });
|
||||
}
|
||||
function __pathAt(p, pct, tangent) {
|
||||
var segs = __pathSegs(p);
|
||||
if (!segs.length) return tangent ? [1, 0] : p.__pts.length ? [p.__pts[0][0], p.__pts[0][1]] : [0, 0];
|
||||
var N = 32, table = [], total = 0;
|
||||
for (var k = 0; k < segs.length; k++) {
|
||||
var prev = __bezAt(segs[k], 0);
|
||||
for (var s = 1; s <= N; s++) {
|
||||
var q = __bezAt(segs[k], s / N);
|
||||
total += Math.sqrt((q[0] - prev[0]) * (q[0] - prev[0]) + (q[1] - prev[1]) * (q[1] - prev[1]));
|
||||
table.push([total, k, s / N]);
|
||||
prev = q;
|
||||
}
|
||||
}
|
||||
var target = Math.max(0, Math.min(1, pct)) * total;
|
||||
var i = 0;
|
||||
while (i < table.length - 1 && table[i][0] < target) i++;
|
||||
var e = table[i], seg = e[1];
|
||||
var l0 = i > 0 ? table[i - 1][0] : 0, u0 = i > 0 && table[i - 1][1] === seg ? table[i - 1][2] : 0;
|
||||
var u = e[0] > l0 ? u0 + (e[2] - u0) * (target - l0) / (e[0] - l0) : e[2];
|
||||
if (!tangent) return __bezAt(segs[seg], u);
|
||||
var d = __bezDer(segs[seg], u), len = Math.sqrt(d[0] * d[0] + d[1] * d[1]);
|
||||
return len > 0 ? [d[0] / len, d[1] / len] : [1, 0];
|
||||
}
|
||||
|
||||
function createPath(points, inTangents, outTangents, isClosed) {
|
||||
points = points || [];
|
||||
|
|
@ -734,6 +780,9 @@ Prop.prototype = {
|
|||
inTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).inTangents(); },
|
||||
outTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).outTangents(); },
|
||||
isClosed: function () { return this.value.isClosed(); },
|
||||
pointOnPath: function (pct, t) { return this.valueAtTime(t === undefined ? time : t).pointOnPath(pct); },
|
||||
tangentOnPath: function (pct, t) { return this.valueAtTime(t === undefined ? time : t).tangentOnPath(pct); },
|
||||
normalOnPath: function (pct, t) { return this.valueAtTime(t === undefined ? time : t).normalOnPath(pct); },
|
||||
// Source Text style API.
|
||||
__docAt: function (t) {
|
||||
var d = __h(__DOC, this.__c, this.__l, this.__p, t, this.__self);
|
||||
|
|
|
|||
|
|
@ -1116,6 +1116,10 @@ fn project_and_time_globals() {
|
|||
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], true).points()[2][1]", 0.0), 10.0);
|
||||
assert_close!(f.num("createPath([[0, 0], [10, 0]], [[1, 1], [2, 2]], [], false).inTangents()[1][0]", 0.0), 2.0);
|
||||
assert_close!(f.num("mask(\"Mask 1\").maskPath.points().length", 0.0), 4.0);
|
||||
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).pointOnPath(0.75)[1]", 0.0), 5.0);
|
||||
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).pointOnPath(0.75)[0]", 0.0), 10.0);
|
||||
assert_close!(f.num("createPath([[0, 0], [10, 0], [10, 10]], [], [], false).tangentOnPath(0.25)[0]", 0.0), 1.0);
|
||||
assert_close!(f.num("mask(\"Mask 1\").maskPath.pointOnPath(0)[0] - mask(\"Mask 1\").maskPath.points()[0][0]", 0.0), 0.0);
|
||||
assert_close!(f.num("mask(\"Mask 1\").maskPath.isClosed() ? 1 : 0", 0.0), 1.0);
|
||||
// Marker keys carry protected regions and cue points.
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
//! Effects' Master Properties).
|
||||
//!
|
||||
//! - A comp's [`EssentialGraphics`] lists controls: properties of its layers (shown as text,
|
||||
//! colour, slider, checkbox, point, angle or dropdown controls), Media Replacement slots for
|
||||
//! colour, slider, checkbox, point, angle or dropdown controls; Source Text also as a font
|
||||
//! control and Scale as one uniform slider), Media Replacement slots for
|
||||
//! footage layers, comments and groups.
|
||||
//! - Every layer that nests such a comp gets an **Essential Properties** group (match id
|
||||
//! `essential`, [`GroupKind::Essential`]) mirroring the controls, one child per control with
|
||||
|
|
@ -36,6 +37,11 @@ pub struct EgControl {
|
|||
/// Display name (renamable; defaults to the property's name).
|
||||
pub name: String,
|
||||
pub kind: EgKind,
|
||||
/// The control a property is shown as when not its natural one ([`control_type`]): a
|
||||
/// Source Text property as a [`ControlType::Font`] control, a Scale as a uniform
|
||||
/// [`ControlType::Scale`] slider.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub as_type: Option<ControlType>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -66,11 +72,34 @@ pub enum ControlType {
|
|||
Angle,
|
||||
Dropdown,
|
||||
Media,
|
||||
/// The font family, style and size of a Source Text property (the text itself stays).
|
||||
Font,
|
||||
/// One uniform percentage for a Scale property (every axis the same).
|
||||
Scale,
|
||||
}
|
||||
|
||||
impl ControlType {
|
||||
/// By label or serde name, case-insensitively (`"font"`, `"Scale"`…).
|
||||
pub fn parse(s: &str) -> Option<ControlType> {
|
||||
[
|
||||
ControlType::Text,
|
||||
ControlType::Color,
|
||||
ControlType::Slider,
|
||||
ControlType::Checkbox,
|
||||
ControlType::Point,
|
||||
ControlType::Angle,
|
||||
ControlType::Dropdown,
|
||||
ControlType::Media,
|
||||
ControlType::Font,
|
||||
ControlType::Scale,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|t| t.label().eq_ignore_ascii_case(s.trim()))
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ControlType::Font => "Font",
|
||||
ControlType::Scale => "Scale",
|
||||
ControlType::Text => "Text",
|
||||
ControlType::Color => "Color",
|
||||
ControlType::Slider => "Slider",
|
||||
|
|
@ -101,6 +130,35 @@ pub fn control_type(p: &Property) -> Option<ControlType> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Whether property `p` can be shown as a `ty` control: its natural control, a Source Text
|
||||
/// property as Font, a two- or three-dimensional property as uniform Scale.
|
||||
pub fn can_show_as(p: &Property, ty: ControlType) -> bool {
|
||||
if matches!(p.ui, ParamUi::Hidden) {
|
||||
return false;
|
||||
}
|
||||
match ty {
|
||||
ControlType::Font => matches!(p.value, Value::Text(_)),
|
||||
ControlType::Scale => matches!(p.value, Value::Vec2(_) | Value::Vec3(_)) && !matches!(p.ui, ParamUi::Angle),
|
||||
ControlType::Media => false,
|
||||
t => control_type(p) == Some(t),
|
||||
}
|
||||
}
|
||||
|
||||
/// The control `c` shows for its property `p` (its `as_type`, else the natural one).
|
||||
pub fn effective_type(c: &EgControl, p: &Property) -> Option<ControlType> {
|
||||
c.as_type.filter(|t| can_show_as(p, *t)).or_else(|| control_type(p))
|
||||
}
|
||||
|
||||
/// Apply a Font control's value: the font family, style and size of `from` onto `to` (the text,
|
||||
/// colours and every other attribute stay).
|
||||
pub fn merge_font(to: &mut Value, from: &Value) {
|
||||
if let (Value::Text(t), Value::Text(f)) = (to, from) {
|
||||
t.font = f.font.clone();
|
||||
t.style = f.style.clone();
|
||||
t.size = f.size;
|
||||
}
|
||||
}
|
||||
|
||||
impl EssentialGraphics {
|
||||
/// Every control, depth first (groups before their children).
|
||||
pub fn flat(&self) -> Vec<&EgControl> {
|
||||
|
|
@ -159,6 +217,11 @@ impl EssentialGraphics {
|
|||
pub fn control_for(&self, layer: LayerId, prop: Uid) -> Option<&EgControl> {
|
||||
self.flat().into_iter().find(|c| matches!(c.kind, EgKind::Property { layer: l, prop: p } if l == layer && p == prop))
|
||||
}
|
||||
/// The control exposing property `prop` of `layer` as `as_type` (`None` = its natural
|
||||
/// control): a property can have one control of each kind (Source Text as Text and Font).
|
||||
pub fn control_for_as(&self, layer: LayerId, prop: Uid, as_type: Option<ControlType>) -> Option<&EgControl> {
|
||||
self.flat().into_iter().find(|c| c.as_type == as_type && matches!(c.kind, EgKind::Property { layer: l, prop: p } if l == layer && p == prop))
|
||||
}
|
||||
/// Controls an instance can override (properties and media; not comments or groups).
|
||||
pub fn has_instance_controls(&self) -> bool {
|
||||
self.flat().iter().any(|c| matches!(c.kind, EgKind::Property { .. } | EgKind::Media { .. }))
|
||||
|
|
@ -385,9 +448,22 @@ pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) -
|
|||
let mut p = project.clone();
|
||||
let c = p.comp_mut(comp)?;
|
||||
let mut any = false;
|
||||
for o in overrides {
|
||||
// Whole values first, then Font controls (which change only the font of whatever text the
|
||||
// instance shows).
|
||||
let is_font = |o: &&Override| eg.find(o.control).is_some_and(|c| c.as_type == Some(ControlType::Font));
|
||||
let ordered = overrides.iter().filter(|o| !is_font(o)).chain(overrides.iter().filter(is_font));
|
||||
for o in ordered {
|
||||
let Some(ctl) = eg.find(o.control) else { continue };
|
||||
match ctl.kind {
|
||||
EgKind::Property { layer, prop } if ctl.as_type == Some(ControlType::Font) => {
|
||||
if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) {
|
||||
merge_font(&mut pr.value, &o.value);
|
||||
for k in &mut pr.keys {
|
||||
merge_font(&mut k.value, &o.value);
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
EgKind::Property { layer, prop } => {
|
||||
if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) {
|
||||
pr.keys.clear();
|
||||
|
|
|
|||
|
|
@ -37,7 +37,13 @@
|
|||
//! `onChange` / `onChanging` / `onClose`, `show()` / `close()` and `layout.layout()`. Windows
|
||||
//! are published to [`Session::script_ui`](effectcraft_engine::Session::script_ui) for
|
||||
//! frontends and agents (`scriptui.*` commands); modal dialogs block `show()` until they close.
|
||||
//! Scripts in the ScriptUI Panels folder run with `this` = a dockable `Panel`.
|
||||
//! Scripts in the ScriptUI Panels folder run with `this` = a dockable `Panel`. Resource
|
||||
//! strings (`new Window("dialog { ok: Button { text: 'OK' } }")`, `add("group { … }")`) build
|
||||
//! whole trees; `onDraw` handlers paint with `ScriptUIGraphics` (`newPen`/`newBrush`/`rectPath`/
|
||||
//! `ellipsePath`/`moveTo`/`lineTo`/`fillPath`/`strokePath`/`drawString`/`measureString`) into a
|
||||
//! serde draw list frontends paint; edit texts and sliders fire `onChanging` live.
|
||||
//! * **Sockets** (`socket.rs`): `Socket` (`open`/`listen`/`poll`/`read`/`readln`/`write`/`close`)
|
||||
//! over TCP, behind the same network preference; on the web `open` returns false.
|
||||
//!
|
||||
//! Entry points: [`install`] sets [`Session::script`](effectcraft_engine::Session::script), which
|
||||
//! the `script.run` command, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`), the Script Console
|
||||
|
|
@ -48,6 +54,7 @@
|
|||
pub mod matchnames;
|
||||
mod model;
|
||||
mod runtime;
|
||||
mod socket;
|
||||
mod ui;
|
||||
|
||||
use effectcraft_engine::{ScriptRequest, Session};
|
||||
|
|
@ -75,3 +82,5 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome {
|
|||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_ui;
|
||||
#[cfg(test)]
|
||||
mod tests_ui_more;
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ File.prototype = {
|
|||
read: function (n) {
|
||||
if (this.__buf === null) this.open("r");
|
||||
if (this.__buf === null) return "";
|
||||
var s = n === undefined ? this.__buf.substring(this.__pos) : this.__buf.substr(this.__pos, n);
|
||||
var s = n === undefined ? this.__buf.substring(this.__pos) : this.__buf.substring(this.__pos, this.__pos + n);
|
||||
this.__pos += s.length;
|
||||
return s;
|
||||
},
|
||||
|
|
@ -255,7 +255,77 @@ Object.defineProperty(Folder, "temp", { get: function () { return null; } });
|
|||
Object.defineProperty(Folder, "desktop", { get: function () { return null; } });
|
||||
Object.defineProperty(Folder, "myDocuments", { get: function () { return null; } });
|
||||
|
||||
function Socket() { __file("network", ""); }
|
||||
// TCP sockets (behind Allow Scripts to Write Files and Access Network; see socket.rs).
|
||||
function Socket() {
|
||||
if (!(this instanceof Socket)) return new Socket();
|
||||
__sock("check", 0, "{}");
|
||||
this.__id = 0;
|
||||
this.host = "";
|
||||
this.timeout = 10;
|
||||
this.encoding = "ASCII";
|
||||
this.error = "";
|
||||
}
|
||||
function __sockCall(s, op, extra) {
|
||||
var p = extra || {};
|
||||
p.timeout = s.timeout;
|
||||
p.encoding = s.encoding;
|
||||
var r = JSON.parse(__sock(op, s.__id, JSON.stringify(p)));
|
||||
s.error = r.error || "";
|
||||
return r;
|
||||
}
|
||||
Socket.prototype = {
|
||||
constructor: Socket,
|
||||
get connected() { return this.__id > 0 && __sockCall(this, "connected").value === true; },
|
||||
get eof() { return this.__id === 0 || __sockCall(this, "eof").value === true; },
|
||||
open: function (host, encoding) {
|
||||
if (this.__id) this.close();
|
||||
if (encoding) this.encoding = String(encoding);
|
||||
var r = __sockCall(this, "open", { host: String(host) });
|
||||
if (r.error) return false;
|
||||
this.__id = r.id;
|
||||
this.host = String(host);
|
||||
return true;
|
||||
},
|
||||
listen: function (port, encoding) {
|
||||
if (this.__id) this.close();
|
||||
if (encoding) this.encoding = String(encoding);
|
||||
var r = __sockCall(this, "listen", { port: __num(port, "port") });
|
||||
if (r.error) return false;
|
||||
this.__id = r.id;
|
||||
this.host = "127.0.0.1:" + r.port;
|
||||
this.port = r.port;
|
||||
return true;
|
||||
},
|
||||
poll: function () {
|
||||
if (!this.__id) return null;
|
||||
var r = __sockCall(this, "poll");
|
||||
if (r.id === null || r.id === undefined) return null;
|
||||
var s = Object.create(Socket.prototype);
|
||||
s.__id = r.id;
|
||||
s.host = r.host || "";
|
||||
s.timeout = this.timeout;
|
||||
s.encoding = this.encoding;
|
||||
s.error = "";
|
||||
return s;
|
||||
},
|
||||
read: function (count) {
|
||||
if (!this.__id) return "";
|
||||
return __sockCall(this, "read", { count: count === undefined ? -1 : __num(count, "count") }).data || "";
|
||||
},
|
||||
readln: function () { return this.__id ? __sockCall(this, "readln").data || "" : ""; },
|
||||
write: function () {
|
||||
if (!this.__id) return false;
|
||||
var s = Array.prototype.slice.call(arguments).map(__str).join("");
|
||||
return !__sockCall(this, "write", { data: s }).error;
|
||||
},
|
||||
writeln: function () { return this.write(Array.prototype.slice.call(arguments).map(__str).join("") + "\n"); },
|
||||
close: function () {
|
||||
if (this.__id) __sockCall(this, "close");
|
||||
this.__id = 0;
|
||||
return true;
|
||||
},
|
||||
toString: function () { return "[object Socket]"; }
|
||||
};
|
||||
var system = { callSystem: function () { throw __err("scripts can't run system commands"); } };
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
|
@ -645,6 +715,19 @@ Property.prototype.property = function () { return null; };
|
|||
});
|
||||
def("isDropdownEffect", function () { return false; });
|
||||
|
||||
// Essential Graphics: expose this property in `comp` (its own composition).
|
||||
P.canAddToMotionGraphicsTemplate = function (comp) {
|
||||
if (!comp || comp.__id !== this.__layer.__comp) return false;
|
||||
return __call("essential.canAdd", this.__ref()).ok === true;
|
||||
};
|
||||
P.addToMotionGraphicsTemplateAs = function (comp, name) {
|
||||
if (!this.canAddToMotionGraphicsTemplate(comp)) return false;
|
||||
var p = this.__ref();
|
||||
if (name !== undefined && name !== null) p.name = String(name);
|
||||
__call("essential.addProperty", p);
|
||||
return true;
|
||||
};
|
||||
P.addToMotionGraphicsTemplate = function (comp) { return this.addToMotionGraphicsTemplateAs(comp); };
|
||||
P.getSeparationFollower = function (dim) {
|
||||
return this.parentProperty.property(["ADBE Position_0", "ADBE Position_1", "ADBE Position_2"][dim]);
|
||||
};
|
||||
|
|
@ -805,8 +888,6 @@ Property.prototype.property = function () { return null; };
|
|||
};
|
||||
P.keyLabel = function () { return 0; };
|
||||
P.setLabelAtKey = function () {};
|
||||
P.addToMotionGraphicsTemplate = function () { return false; };
|
||||
P.canAddToMotionGraphicsTemplate = function () { return false; };
|
||||
})();
|
||||
|
||||
// Layer / composition markers (`layer.marker`, `comp.markerProperty`): keyframes are markers.
|
||||
|
|
@ -1352,7 +1433,47 @@ CompItem.prototype.constructor = CompItem;
|
|||
};
|
||||
P.openInViewer = function () { __call("comp.open", { comp: this.__id }); return null; };
|
||||
P.duplicate = function () { return __item(__call("project.duplicate", { items: [this.__id] }).items[0]); };
|
||||
P.exportAsMotionGraphicsTemplate = function () { return false; };
|
||||
// Essential Graphics (templates are EffectCraft's open .ectemplate files).
|
||||
P.__eg = function () { return __call("essential.list", { comp: this.__id }); };
|
||||
P.__controllers = function () {
|
||||
var out = [];
|
||||
(function walk(list) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i].kind === "property" || list[i].kind === "media") out.push(list[i]);
|
||||
if (list[i].children) walk(list[i].children);
|
||||
}
|
||||
})(this.__eg().controls);
|
||||
return out;
|
||||
};
|
||||
def("motionGraphicsTemplateName", function () { return this.__eg().name; }, function (v) {
|
||||
__call("essential.setName", { comp: this.__id, name: String(v) });
|
||||
});
|
||||
def("motionGraphicsTemplateControllerCount", function () { return this.__controllers().length; });
|
||||
P.getMotionGraphicsTemplateControllerName = function (i) {
|
||||
var c = this.__controllers();
|
||||
i = __num(i, "index");
|
||||
if (i < 1 || i > c.length) throw __err("controller index " + i + " is out of range 1.." + c.length);
|
||||
return c[i - 1].name;
|
||||
};
|
||||
P.setMotionGraphicsControllerName = function (i, name) {
|
||||
var c = this.__controllers();
|
||||
i = __num(i, "index");
|
||||
if (i < 1 || i > c.length) throw __err("controller index " + i + " is out of range 1.." + c.length);
|
||||
__call("essential.rename", { comp: this.__id, control: c[i - 1].id, name: String(name) });
|
||||
return String(name);
|
||||
};
|
||||
P.openInEssentialGraphics = function () { __call("comp.openInEssentialGraphics", { comp: this.__id }); };
|
||||
P.exportAsMotionGraphicsTemplate = function (overwrite, path) {
|
||||
var f = __str(path);
|
||||
if (!f) return false;
|
||||
if (!/\.[A-Za-z0-9]+$/.test(f)) f += ".ectemplate";
|
||||
if (!overwrite && JSON.parse(__file("exists", f)) === true) return false;
|
||||
if (this.__controllers().length === 0) return false;
|
||||
// The file gate applies to templates too.
|
||||
__file("write", f, "");
|
||||
__call("essential.exportTemplate", { comp: this.__id, path: f });
|
||||
return true;
|
||||
};
|
||||
})();
|
||||
|
||||
function FootageItem(id) {
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ fn normalize(p: &str) -> std::path::PathBuf {
|
|||
.unwrap_or(out)
|
||||
}
|
||||
|
||||
const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network";
|
||||
pub(crate) const GATE: &str = "Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network";
|
||||
|
||||
/// The file-access gate: reads under the project's folder are always allowed; other reads, all
|
||||
/// writes and listing other folders need the Allow Scripts to Write Files preference.
|
||||
|
|
@ -375,13 +375,14 @@ pub(crate) fn new_context() -> Result<Context, String> {
|
|||
let limits = ctx.runtime_limits_mut();
|
||||
limits.set_loop_iteration_limit(200_000_000);
|
||||
limits.set_recursion_limit(2000);
|
||||
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 9] = [
|
||||
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 10] = [
|
||||
(js_string!("__exec"), 2, native_exec),
|
||||
(js_string!("__query"), 2, native_query),
|
||||
(js_string!("__print"), 1, native_print),
|
||||
(js_string!("__undo"), 2, native_undo),
|
||||
(js_string!("__task"), 3, native_task),
|
||||
(js_string!("__file"), 3, native_file),
|
||||
(js_string!("__sock"), 3, crate::socket::native_sock),
|
||||
(js_string!("__uiNewId"), 0, crate::ui::native_new_id),
|
||||
(js_string!("__uiModal"), 1, crate::ui::native_modal),
|
||||
(js_string!("__uiLayoutNative"), 1, crate::ui::native_layout),
|
||||
|
|
|
|||
|
|
@ -58,13 +58,205 @@ var __uiContainers = { window: 1, panel: 1, group: 1, tabbedpanel: 1, tab: 1 };
|
|||
|
||||
function Window(type, title, bounds, props) {
|
||||
if (!(this instanceof Window)) return new Window(type, title, bounds, props);
|
||||
type = String(type === undefined ? "window" : type).toLowerCase();
|
||||
if (type.indexOf("{") >= 0) throw __err("ScriptUI resource strings are not supported: build the window with add()");
|
||||
type = String(type === undefined ? "window" : type);
|
||||
var res = null;
|
||||
if (type.indexOf("{") >= 0) {
|
||||
// A resource string: `dialog { text: 'Title', ok: Button { text: 'OK' } }`.
|
||||
res = __uiParseRes(type);
|
||||
type = res.type;
|
||||
var p0 = __uiResProps(res).props;
|
||||
if (title === undefined && p0.text !== undefined) title = p0.text;
|
||||
if (bounds === undefined && p0.bounds !== undefined) bounds = p0.bounds;
|
||||
if (props === undefined && p0.properties !== undefined) props = p0.properties;
|
||||
}
|
||||
type = type.toLowerCase();
|
||||
if (type !== "dialog" && type !== "palette" && type !== "window") throw __err("Bad window type " + type);
|
||||
__uiInit(this, "window", null, null, bounds, title, props);
|
||||
this.__kind = type;
|
||||
this.__root = true;
|
||||
__uiWins.push(this);
|
||||
if (res) __uiApplyRes(this, res);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- resource strings
|
||||
//
|
||||
// The resource specification the JavaScript Tools Guide documents: `type { name: value, … }`
|
||||
// where a value is a string ('…' or "…"), a number, true / false, an array `[…]`, an object
|
||||
// literal `{…}` (e.g. `properties: {multiline: true}`) or a nested element `Type { … }` (a
|
||||
// child control, also reachable as `parent.name`). Comments are allowed.
|
||||
|
||||
function __uiParseRes(src) {
|
||||
var s = String(src), i = 0;
|
||||
function fail(m) { throw __err("ScriptUI resource string: " + m + " at offset " + i); }
|
||||
function ws() {
|
||||
for (;;) {
|
||||
while (i < s.length && /\s/.test(s.charAt(i))) i++;
|
||||
if (s.substring(i, i + 2) === "//") {
|
||||
while (i < s.length && s.charAt(i) !== "\n") i++;
|
||||
continue;
|
||||
}
|
||||
if (s.substring(i, i + 2) === "/*") {
|
||||
var e = s.indexOf("*/", i + 2);
|
||||
i = e < 0 ? s.length : e + 2;
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
function ident() {
|
||||
ws();
|
||||
var m = /^[A-Za-z_$][\w$]*/.exec(s.slice(i));
|
||||
if (!m) fail("expected a name");
|
||||
i += m[0].length;
|
||||
return m[0];
|
||||
}
|
||||
function str(q) {
|
||||
i++;
|
||||
var out = "";
|
||||
while (i < s.length && s.charAt(i) !== q) {
|
||||
var c = s.charAt(i);
|
||||
if (c === "\\") {
|
||||
i++;
|
||||
var e = s.charAt(i);
|
||||
out += e === "n" ? "\n" : e === "t" ? "\t" : e === "r" ? "\r" : e;
|
||||
} else out += c;
|
||||
i++;
|
||||
}
|
||||
if (i >= s.length) fail("unterminated string");
|
||||
i++;
|
||||
return out;
|
||||
}
|
||||
function value() {
|
||||
ws();
|
||||
var c = s.charAt(i);
|
||||
if (c === "'" || c === '"') return str(c);
|
||||
if (c === "[") {
|
||||
i++;
|
||||
var a = [];
|
||||
ws();
|
||||
if (s.charAt(i) === "]") {
|
||||
i++;
|
||||
return a;
|
||||
}
|
||||
for (;;) {
|
||||
a.push(value());
|
||||
ws();
|
||||
if (s.charAt(i) === ",") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (s.charAt(i) === "]") {
|
||||
i++;
|
||||
return a;
|
||||
}
|
||||
fail("expected , or ]");
|
||||
}
|
||||
}
|
||||
if (c === "{") return { __object: body() };
|
||||
var m = /^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?/.exec(s.slice(i));
|
||||
if (m) {
|
||||
i += m[0].length;
|
||||
return Number(m[0]);
|
||||
}
|
||||
var id = ident();
|
||||
if (id === "true") return true;
|
||||
if (id === "false") return false;
|
||||
if (id === "null" || id === "undefined") return null;
|
||||
ws();
|
||||
if (s.charAt(i) === "{") return { __element: id, list: body() };
|
||||
// A bare word (an enum-like value).
|
||||
return id;
|
||||
}
|
||||
function body() {
|
||||
i++;
|
||||
var list = [];
|
||||
for (;;) {
|
||||
ws();
|
||||
if (s.charAt(i) === "}") {
|
||||
i++;
|
||||
return list;
|
||||
}
|
||||
var k = s.charAt(i) === "'" || s.charAt(i) === '"' ? str(s.charAt(i)) : ident();
|
||||
ws();
|
||||
if (s.charAt(i) !== ":") fail("expected : after " + k);
|
||||
i++;
|
||||
list.push([k, value()]);
|
||||
ws();
|
||||
if (s.charAt(i) === ",") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (s.charAt(i) === "}") {
|
||||
i++;
|
||||
return list;
|
||||
}
|
||||
fail("expected , or }");
|
||||
}
|
||||
}
|
||||
var t = ident();
|
||||
ws();
|
||||
if (s.charAt(i) !== "{") fail("expected { after " + t);
|
||||
var list = body();
|
||||
ws();
|
||||
if (i < s.length) fail("unexpected text after the resource");
|
||||
return { type: t, list: list };
|
||||
}
|
||||
|
||||
// The plain JavaScript value of a parsed resource value (object literals become objects).
|
||||
function __uiResPlain(v) {
|
||||
if (v instanceof Array) return v.map(__uiResPlain);
|
||||
if (v && typeof v === "object" && v.__object) {
|
||||
var o = {};
|
||||
for (var k = 0; k < v.__object.length; k++) o[v.__object[k][0]] = __uiResPlain(v.__object[k][1]);
|
||||
return o;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// A resource's own properties and its child elements (in source order).
|
||||
function __uiResProps(res) {
|
||||
var props = {}, kids = [];
|
||||
for (var k = 0; k < res.list.length; k++) {
|
||||
var e = res.list[k];
|
||||
if (e[1] && typeof e[1] === "object" && e[1].__element) kids.push([e[0], { type: e[1].__element, list: e[1].list }]);
|
||||
else props[e[0]] = __uiResPlain(e[1]);
|
||||
}
|
||||
return { props: props, kids: kids };
|
||||
}
|
||||
|
||||
var __uiResSkip = { text: 1, bounds: 1, properties: 1, minvalue: 1, maxvalue: 1, items: 1 };
|
||||
var __uiSizes = { preferredSize: 1, minimumSize: 1, maximumSize: 1, size: 1, location: 1 };
|
||||
|
||||
// Set a control's properties and create its children from a parsed resource.
|
||||
function __uiApplyRes(c, res) {
|
||||
var pk = __uiResProps(res), props = pk.props;
|
||||
// Ranges before values, items before selections.
|
||||
if (props.minvalue !== undefined) c.minvalue = __num(props.minvalue);
|
||||
if (props.maxvalue !== undefined) c.maxvalue = __num(props.maxvalue);
|
||||
if (props.items instanceof Array && c.items) for (var n = 0; n < props.items.length; n++) c.add("item", props.items[n]);
|
||||
for (var k in props) {
|
||||
if (__uiResSkip[k]) continue;
|
||||
var v = props[k];
|
||||
if (__uiSizes[k] && v instanceof Array) v = __dim(v[0], v[1]);
|
||||
c[k] = v;
|
||||
}
|
||||
for (var j = 0; j < pk.kids.length; j++) {
|
||||
var child = __uiAddRes(c, pk.kids[j][1]);
|
||||
child.__key = pk.kids[j][0];
|
||||
c[pk.kids[j][0]] = child;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// add() with a resource string (or a parsed element): the new control.
|
||||
function __uiAddRes(parent, spec) {
|
||||
var res = typeof spec === "string" ? __uiParseRes(spec) : spec;
|
||||
var p = __uiResProps(res).props;
|
||||
var t = String(res.type).toLowerCase();
|
||||
var valued = t === "dropdownlist" || t === "listbox" || t === "slider" || t === "scrollbar" || t === "progressbar";
|
||||
var c = parent.add(t, p.bounds, valued ? undefined : p.text, p.properties);
|
||||
if (valued && p.text !== undefined) c.text = String(p.text);
|
||||
return __uiApplyRes(c, res);
|
||||
}
|
||||
Window.alert = function (s) { alert(s); };
|
||||
Window.confirm = function (s) { return confirm(s); };
|
||||
|
|
@ -106,18 +298,95 @@ var ScriptUI = {
|
|||
events: { createEvent: function (t) { return { type: t }; } }
|
||||
};
|
||||
|
||||
// A colour for the draw list: [r, g, b, a] in 0..1, or null for a theme colour (by name).
|
||||
function __uiColor(c) {
|
||||
if (c instanceof Array && c.length >= 3) return [__num(c[0]), __num(c[1]), __num(c[2]), c.length > 3 ? __num(c[3]) : 1];
|
||||
return null;
|
||||
}
|
||||
function __uiFontSize(f, g) {
|
||||
var x = f && f.size !== undefined ? Number(f.size) : g && g.font && g.font.size !== undefined ? Number(g.font.size) : 12;
|
||||
return x > 0 ? x : 12;
|
||||
}
|
||||
|
||||
// ScriptUIGraphics: what onDraw handlers paint with. Paths and paint calls are recorded into the
|
||||
// control's draw list (`draw` in the window snapshot), which frontends paint.
|
||||
function __uiGraphics() {
|
||||
return {
|
||||
newBrush: function (t, c) { return { type: t, color: c }; },
|
||||
newPen: function (t, c, w) { return { type: t, color: c, lineWidth: w }; },
|
||||
newPath: function () {}, moveTo: function () {}, lineTo: function () {}, rectPath: function () {},
|
||||
ellipsePath: function () {}, fillPath: function () {}, strokePath: function () {}, closePath: function () {},
|
||||
drawString: function () {}, drawOSControl: function () {}, drawImage: function () {}, drawFocusRing: function () {},
|
||||
measureString: function (s) { return __dim(String(s).length * 7, 16); },
|
||||
font: null, foregroundColor: null, backgroundColor: null, disabledForegroundColor: null, disabledBackgroundColor: null
|
||||
BrushType: ScriptUI.BrushType,
|
||||
PenType: ScriptUI.PenType,
|
||||
font: ScriptUI.newFont("dialog", "REGULAR", 12),
|
||||
foregroundColor: null, backgroundColor: null, disabledForegroundColor: null, disabledBackgroundColor: null,
|
||||
currentPath: [],
|
||||
currentPoint: [0, 0],
|
||||
__rec: null,
|
||||
__push: function (op) { if (this.__rec) this.__rec.push(op); },
|
||||
newBrush: function (t, c) { return { type: t, color: __uiColor(c), theme: typeof c === "string" ? c : "" }; },
|
||||
newPen: function (t, c, w) { return { type: t, color: __uiColor(c), theme: typeof c === "string" ? c : "", lineWidth: w === undefined ? 1 : __num(w) }; },
|
||||
newPath: function () { this.currentPath = []; return this.currentPath; },
|
||||
moveTo: function (x, y) { this.currentPath.push({ k: "M", x: __num(x), y: __num(y) }); this.currentPoint = [__num(x), __num(y)]; },
|
||||
lineTo: function (x, y) { this.currentPath.push({ k: "L", x: __num(x), y: __num(y) }); this.currentPoint = [__num(x), __num(y)]; },
|
||||
closePath: function () { this.currentPath.push({ k: "Z" }); },
|
||||
rectPath: function (x, y, w, h) {
|
||||
if (x instanceof Array) { h = x[3] - x[1]; w = x[2] - x[0]; y = x[1]; x = x[0]; }
|
||||
this.currentPath.push({ k: "R", x: __num(x), y: __num(y), w: __num(w), h: __num(h) });
|
||||
return this.currentPath;
|
||||
},
|
||||
ellipsePath: function (x, y, w, h) {
|
||||
if (x instanceof Array) { h = x[3] - x[1]; w = x[2] - x[0]; y = x[1]; x = x[0]; }
|
||||
this.currentPath.push({ k: "E", x: __num(x), y: __num(y), w: __num(w), h: __num(h) });
|
||||
return this.currentPath;
|
||||
},
|
||||
fillPath: function (brush, path) {
|
||||
this.__push({ op: "fill", color: brush ? brush.color : null, path: (path || this.currentPath).slice() });
|
||||
},
|
||||
strokePath: function (pen, path) {
|
||||
this.__push({ op: "stroke", color: pen ? pen.color : null, width: pen && pen.lineWidth !== undefined ? pen.lineWidth : 1, path: (path || this.currentPath).slice() });
|
||||
},
|
||||
drawString: function (text, pen, x, y, font) {
|
||||
var f = font || this.font;
|
||||
this.__push({ op: "text", text: String(text), color: pen ? pen.color : null, x: __num(x || 0), y: __num(y || 0), size: __uiFontSize(f, this), style: String(f && f.style !== undefined ? f.style : "") });
|
||||
},
|
||||
drawOSControl: function () { this.__push({ op: "os" }); },
|
||||
drawImage: function (img, x, y, w, h) { this.__push({ op: "image", x: __num(x || 0), y: __num(y || 0), w: w === undefined ? 0 : __num(w), h: h === undefined ? 0 : __num(h) }); },
|
||||
drawFocusRing: function () {},
|
||||
measureString: function (s, font, boundaryWidth) {
|
||||
var size = __uiFontSize(font, this);
|
||||
var lines = String(s).split("\n");
|
||||
var cw = size * 0.58, w = 0;
|
||||
for (var i = 0; i < lines.length; i++) w = Math.max(w, Math.ceil(lines[i].length * cw));
|
||||
var n = lines.length;
|
||||
if (boundaryWidth > 0 && w > boundaryWidth) {
|
||||
n = Math.ceil(w / boundaryWidth) + lines.length - 1;
|
||||
w = boundaryWidth;
|
||||
}
|
||||
return __dim(w, Math.ceil(size * 1.25) * n);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Run the onDraw handlers of a window's controls (after laying it out, so `size` is known),
|
||||
// recording what they paint.
|
||||
function __uiRunDraws(win) {
|
||||
var drawn = [];
|
||||
(function walk(c) {
|
||||
if (typeof c.onDraw === "function") drawn.push(c);
|
||||
for (var i = 0; i < (c.children || []).length; i++) walk(c.children[i]);
|
||||
})(win);
|
||||
if (!drawn.length) return;
|
||||
__uiLayout(win);
|
||||
for (var k = 0; k < drawn.length; k++) {
|
||||
var c = drawn[k];
|
||||
c.__draw = [];
|
||||
c.graphics.__rec = c.__draw;
|
||||
try {
|
||||
c.onDraw.call(c, { type: "draw", target: c });
|
||||
} catch (e) {
|
||||
try { writeLn("onDraw: " + (e && e.message ? e.message : e)); } catch (e2) {}
|
||||
}
|
||||
c.graphics.__rec = null;
|
||||
}
|
||||
}
|
||||
|
||||
function __uiInit(o, type, win, parent, bounds, text, props) {
|
||||
o.type = type;
|
||||
o.window = win || o;
|
||||
|
|
@ -180,6 +449,10 @@ function __uiProto() {
|
|||
return {
|
||||
add: function (type, bounds, text, props) {
|
||||
var t = String(type).toLowerCase();
|
||||
if (t.indexOf("{") >= 0) {
|
||||
if (!__uiContainers[this.type]) throw __err("add(): a " + this.type + " can't contain controls");
|
||||
return __uiAddRes(this, String(type));
|
||||
}
|
||||
if (t === "item" || t === "separator") return this.__addItem(t === "separator" ? "-" : bounds);
|
||||
if (!__uiContainers[this.type]) throw __err("add(): a " + this.type + " can't contain controls");
|
||||
var ctor = __uiTypes[t];
|
||||
|
|
@ -302,7 +575,7 @@ function __uiProto() {
|
|||
},
|
||||
__handlers: function () {
|
||||
var out = [];
|
||||
var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize"];
|
||||
var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize", "onDraw"];
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var n = names[i];
|
||||
if (typeof this[n] === "function" || (this.__listeners[n.slice(2).toLowerCase()] || []).length) out.push(n);
|
||||
|
|
@ -411,7 +684,7 @@ function __uiJson(c) {
|
|||
var o = {
|
||||
id: c.__id,
|
||||
type: c.type,
|
||||
name: String(c.name || c.properties.name || ""),
|
||||
name: String(c.name || c.properties.name || c.__key || ""),
|
||||
text: String(c.text === undefined || c.text === null ? "" : c.text),
|
||||
enabled: c.enabled !== false,
|
||||
visible: c.visible !== false,
|
||||
|
|
@ -444,6 +717,7 @@ function __uiJson(c) {
|
|||
o.selection = c.__sel.slice();
|
||||
}
|
||||
if (c.type === "tabbedpanel") o.activeTab = c.__sel.length ? c.__sel[0] : 0;
|
||||
if (c.__draw && typeof c.onDraw === "function") o.draw = c.__draw;
|
||||
if (c.type === "edittext") {
|
||||
o.multiline = !!c.__multiline;
|
||||
o.readOnly = !!c.__readonly;
|
||||
|
|
@ -456,6 +730,7 @@ function __uiSnapshot() {
|
|||
var out = [];
|
||||
for (var i = 0; i < __uiWins.length; i++) {
|
||||
var w = __uiWins[i];
|
||||
if (w.__shown && !w.__closed) __uiRunDraws(w);
|
||||
out.push({ id: w.__wid, kind: w.__kind, title: String(w.text || ""), visible: !!w.__shown && !w.__closed, modal: !!w.__modal, root: __uiJson(w) });
|
||||
}
|
||||
// Closed windows are forgotten.
|
||||
|
|
@ -529,14 +804,17 @@ function __uiDispatch(winId, id, kind, valueJson) {
|
|||
}
|
||||
return;
|
||||
}
|
||||
// change / changing
|
||||
// change / changing (live: each keystroke of an edit text, each step of a slider drag)
|
||||
var before = c.type === "edittext" ? c.text : c.value;
|
||||
if (c.type === "edittext") c.text = v === null ? "" : String(v);
|
||||
else if (c.type === "statictext") c.text = v === null ? "" : String(v);
|
||||
else if (c.type === "slider" || c.type === "scrollbar" || c.type === "progressbar") c.value = Number(v);
|
||||
else if (c.type === "checkbox") c.value = !!v;
|
||||
else if (c.type === "radiobutton") { if (v) radio(); else c.value = false; }
|
||||
else if (c.type === "dropdownlist" || c.type === "listbox" || c.type === "tabbedpanel") c.selection = v;
|
||||
if (c.type === "edittext" || c.type === "slider" || c.type === "scrollbar") c.__fire("onChanging");
|
||||
var now = c.type === "edittext" ? c.text : c.value;
|
||||
// onChanging already ran for live updates of the same value.
|
||||
if ((c.type === "edittext" || c.type === "slider" || c.type === "scrollbar") && (kind === "changing" || now !== before)) c.__fire("onChanging");
|
||||
if (kind === "change") {
|
||||
if (c.type === "checkbox" || c.type === "radiobutton") c.__fire("onClick");
|
||||
else c.__fire("onChange");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,241 @@
|
|||
//! `Socket`: TCP connections for scripts (the JavaScript Tools Guide's Socket object: `open`,
|
||||
//! `listen`, `poll`, `read`, `readln`, `write`, `writeln`, `close`, `connected`, `eof`).
|
||||
//!
|
||||
//! Network access follows Preferences ▸ Scripting & Expressions ▸ Allow Scripts to Write Files
|
||||
//! and Access Network: with it off, `new Socket()` throws. On the web there are no raw sockets:
|
||||
//! `open` and `listen` return false (with `error` set).
|
||||
//!
|
||||
//! The JavaScript side holds only a handle; the streams live here, keyed by handle, so a script
|
||||
//! window's handlers can keep using a socket opened earlier in the same script.
|
||||
|
||||
use boa_engine::{Context, JsResult, JsValue};
|
||||
use serde_json::{Value as J, json};
|
||||
|
||||
use crate::runtime::{arg_string, js_str, throw, with_active};
|
||||
|
||||
/// `__sock(op, handle, paramsJson)` → result JSON (`{error}` on failure).
|
||||
pub(crate) fn native_sock(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let op = arg_string(args, 0, ctx)?;
|
||||
let id = args.get(1).cloned().unwrap_or_default().to_number(ctx)? as u32;
|
||||
let params = arg_string(args, 2, ctx)?;
|
||||
let p: J = serde_json::from_str(¶ms).unwrap_or(J::Null);
|
||||
let allowed = with_active(|a| Ok(a.session.prefs.scripting.allow_scripts_write_files)).unwrap_or(false);
|
||||
if !allowed {
|
||||
return Err(throw(format!("scripts can't access the network unless {} is on", crate::runtime::GATE)));
|
||||
}
|
||||
if op == "check" {
|
||||
return Ok(js_str("{}"));
|
||||
}
|
||||
let r = imp::run(&op, id, &p);
|
||||
Ok(js_str(&r.to_string()))
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> J {
|
||||
json!({"error": e.to_string()})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod imp {
|
||||
use super::*;
|
||||
pub(super) fn run(op: &str, _: u32, _: &J) -> J {
|
||||
match op {
|
||||
"connected" | "eof" => json!({"value": op == "eof"}),
|
||||
"close" => json!({}),
|
||||
_ => err("sockets are not available in the web version of EffectCraft"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod imp {
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::*;
|
||||
|
||||
enum Sock {
|
||||
Stream { s: TcpStream, buf: Vec<u8>, eof: bool },
|
||||
Listener(TcpListener),
|
||||
}
|
||||
|
||||
struct Table {
|
||||
next: u32,
|
||||
socks: BTreeMap<u32, Sock>,
|
||||
}
|
||||
|
||||
fn table() -> &'static Mutex<Table> {
|
||||
static T: Mutex<Table> = Mutex::new(Table { next: 1, socks: BTreeMap::new() });
|
||||
&T
|
||||
}
|
||||
|
||||
fn add(s: Sock) -> u32 {
|
||||
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let id = t.next;
|
||||
t.next += 1;
|
||||
t.socks.insert(id, s);
|
||||
id
|
||||
}
|
||||
|
||||
fn timeout(p: &J) -> Duration {
|
||||
Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0))
|
||||
}
|
||||
|
||||
/// `host:port` (After Effects' form) → socket addresses.
|
||||
fn addr(host: &str) -> Result<Vec<std::net::SocketAddr>, String> {
|
||||
let a = host.to_socket_addrs().map_err(|e| format!("bad address `{host}` (use host:port): {e}"))?;
|
||||
Ok(a.collect())
|
||||
}
|
||||
|
||||
/// Text ↔ bytes in the socket's encoding: UTF-8, else one byte per character (ASCII /
|
||||
/// BINARY, as in After Effects).
|
||||
fn encode(s: &str, utf8: bool) -> Vec<u8> {
|
||||
if utf8 { s.as_bytes().to_vec() } else { s.chars().map(|c| (c as u32).min(255) as u8).collect() }
|
||||
}
|
||||
fn decode(b: &[u8], utf8: bool) -> String {
|
||||
if utf8 { String::from_utf8_lossy(b).into_owned() } else { b.iter().map(|&c| c as char).collect() }
|
||||
}
|
||||
|
||||
/// Fill a stream's buffer until `done(buf)` or the timeout / end of stream.
|
||||
fn fill(s: &mut TcpStream, buf: &mut Vec<u8>, eof: &mut bool, limit: Duration, done: impl Fn(&[u8]) -> bool) {
|
||||
let end = Instant::now() + limit;
|
||||
let mut chunk = [0u8; 4096];
|
||||
while !done(buf) && !*eof {
|
||||
let left = end.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() {
|
||||
break;
|
||||
}
|
||||
let _ = s.set_read_timeout(Some(left.max(Duration::from_millis(1))));
|
||||
match s.read(&mut chunk) {
|
||||
Ok(0) => *eof = true,
|
||||
Ok(n) => buf.extend_from_slice(&chunk[..n]),
|
||||
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => {
|
||||
if e.kind() != ErrorKind::Interrupted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => *eof = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run(op: &str, id: u32, p: &J) -> J {
|
||||
let utf8 = p.get("encoding").and_then(J::as_str).is_some_and(|e| e.eq_ignore_ascii_case("utf-8") || e.eq_ignore_ascii_case("utf8"));
|
||||
match op {
|
||||
"open" => {
|
||||
let host = p.get("host").and_then(J::as_str).unwrap_or("");
|
||||
let addrs = match addr(host) {
|
||||
Ok(a) => a,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let limit = timeout(p).max(Duration::from_millis(10));
|
||||
let mut last = "no address".to_string();
|
||||
for a in addrs {
|
||||
match TcpStream::connect_timeout(&a, limit) {
|
||||
Ok(s) => {
|
||||
let _ = s.set_nodelay(true);
|
||||
return json!({"id": add(Sock::Stream { s, buf: vec![], eof: false })});
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
err(format!("cannot connect to {host}: {last}"))
|
||||
}
|
||||
"listen" => {
|
||||
let port = p.get("port").and_then(J::as_u64).unwrap_or(0) as u16;
|
||||
match TcpListener::bind(("127.0.0.1", port)).and_then(|l| l.set_nonblocking(true).map(|_| l)) {
|
||||
Ok(l) => {
|
||||
let port = l.local_addr().map(|a| a.port()).unwrap_or(port);
|
||||
json!({"id": add(Sock::Listener(l)), "port": port})
|
||||
}
|
||||
Err(e) => err(format!("cannot listen on port {port}: {e}")),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if op == "close" {
|
||||
t.socks.remove(&id);
|
||||
return json!({});
|
||||
}
|
||||
let Some(sock) = t.socks.get_mut(&id) else {
|
||||
return match op {
|
||||
"connected" => json!({"value": false}),
|
||||
"eof" => json!({"value": true}),
|
||||
_ => err("the socket is not open"),
|
||||
};
|
||||
};
|
||||
match (op, sock) {
|
||||
("poll", Sock::Listener(l)) => match l.accept() {
|
||||
Ok((s, peer)) => {
|
||||
let _ = s.set_nonblocking(false);
|
||||
let host = peer.to_string();
|
||||
let new = Sock::Stream { s, buf: vec![], eof: false };
|
||||
let nid = t.next;
|
||||
t.next += 1;
|
||||
t.socks.insert(nid, new);
|
||||
json!({"id": nid, "host": host})
|
||||
}
|
||||
Err(_) => json!({"id": null}),
|
||||
},
|
||||
("connected", Sock::Stream { s, buf, eof }) => {
|
||||
if !*eof && buf.is_empty() {
|
||||
// Peek without blocking to notice a closed peer.
|
||||
let _ = s.set_nonblocking(true);
|
||||
let mut b = [0u8; 1];
|
||||
match s.peek(&mut b) {
|
||||
Ok(0) => *eof = true,
|
||||
Err(e) if e.kind() != ErrorKind::WouldBlock => *eof = true,
|
||||
_ => {}
|
||||
}
|
||||
let _ = s.set_nonblocking(false);
|
||||
}
|
||||
json!({"value": !(*eof && buf.is_empty())})
|
||||
}
|
||||
("connected", Sock::Listener(_)) => json!({"value": true}),
|
||||
("eof", Sock::Stream { buf, eof, .. }) => json!({"value": *eof && buf.is_empty()}),
|
||||
("read", Sock::Stream { s, buf, eof }) => {
|
||||
let count = p.get("count").and_then(J::as_i64).unwrap_or(-1);
|
||||
let limit = timeout(p);
|
||||
if count < 0 {
|
||||
fill(s, buf, eof, limit, |_| false);
|
||||
let all = std::mem::take(buf);
|
||||
json!({"data": decode(&all, utf8)})
|
||||
} else {
|
||||
let n = count as usize;
|
||||
fill(s, buf, eof, limit, |b| b.len() >= n);
|
||||
let take = n.min(buf.len());
|
||||
let out: Vec<u8> = buf.drain(..take).collect();
|
||||
json!({"data": decode(&out, utf8)})
|
||||
}
|
||||
}
|
||||
("readln", Sock::Stream { s, buf, eof }) => {
|
||||
fill(s, buf, eof, timeout(p), |b| b.contains(&b'\n'));
|
||||
let line: Vec<u8> = match buf.iter().position(|&c| c == b'\n') {
|
||||
Some(i) => {
|
||||
let mut l: Vec<u8> = buf.drain(..=i).collect();
|
||||
l.pop();
|
||||
if l.last() == Some(&b'\r') {
|
||||
l.pop();
|
||||
}
|
||||
l
|
||||
}
|
||||
None => std::mem::take(buf),
|
||||
};
|
||||
json!({"data": decode(&line, utf8)})
|
||||
}
|
||||
("write", Sock::Stream { s, .. }) => {
|
||||
let data = p.get("data").and_then(J::as_str).unwrap_or("");
|
||||
match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) {
|
||||
Ok(()) => json!({"ok": true}),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
("host", Sock::Stream { s, .. }) => json!({"value": s.peer_addr().map(|a| a.to_string()).unwrap_or_default()}),
|
||||
(op, _) => err(format!("`{op}` is not supported on this socket")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -183,9 +183,9 @@ fn layout_and_handler_errors() {
|
|||
assert_eq!(r["ok"], false);
|
||||
assert!(r["error"]["message"].as_str().unwrap().contains("nope"), "{r}");
|
||||
assert_eq!(s.script_ui.windows.len(), 1);
|
||||
// Resource strings aren't supported (a clear error).
|
||||
let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\")", "res.jsx");
|
||||
assert!(o.error.unwrap().message.contains("resource"));
|
||||
// Resource strings build windows too (see tests_ui_more).
|
||||
let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\").text", "res.jsx");
|
||||
assert_eq!(o.result, json!("x"));
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,242 @@
|
|||
//! ScriptUI resource strings, onDraw graphics, live onChanging updates and sockets.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::scriptui::{DrawOp, PathSeg, WidgetKind, WindowKind};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::run_code;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() };
|
||||
crate::install(&mut s);
|
||||
s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default()));
|
||||
s
|
||||
}
|
||||
|
||||
const RESOURCE: &str = r#"
|
||||
var w = new Window("palette { text: 'Res', orientation: 'column', alignChildren: ['fill', 'top'], \
|
||||
g: Group { orientation: 'row', b: Button { text: 'OK', properties: { name: 'okb' } }, cancel: Button { text: \"Can't\" } }, \
|
||||
nm: EditText { text: 'abc', characters: 10 }, \
|
||||
s: Slider { minvalue: 0, maxvalue: 10, value: 4 }, \
|
||||
dd: DropDownList { properties: { items: ['A', 'B'] }, selection: 1 }, \
|
||||
/* a panel */ p: Panel { text: 'Opts', preferredSize: [200, -1], c: Checkbox { text: 'On', value: true } } }");
|
||||
var extra = w.p.add("group { orientation: 'column', t: StaticText { text: 'hi' } }");
|
||||
w.g.b.onClick = function () { writeLn("ok " + w.nm.text + " " + w.s.value); };
|
||||
w.show();
|
||||
[w.text, w.g.orientation, w.g.b.text, w.g.cancel.text, w.s.maxvalue, w.dd.selection.text, w.p.c.value, extra.t.text, w.g.children.length, w.p.preferredSize.width].join("|")
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn resource_strings_build_windows_and_controls() {
|
||||
let mut s = session();
|
||||
let o = run_code(&mut s, RESOURCE, "res.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!("Res|row|OK|Can't|10|B|true|hi|2|200"));
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
assert_eq!((w.kind, w.title.as_str()), (WindowKind::Palette, "Res"));
|
||||
let kinds: Vec<WidgetKind> = w.root.children.iter().map(|c| c.kind).collect();
|
||||
assert_eq!(kinds, [WidgetKind::Group, WidgetKind::EditText, WidgetKind::Slider, WidgetKind::DropDownList, WidgetKind::Panel]);
|
||||
assert_eq!(w.root.children[2].value, 4.0);
|
||||
assert_eq!(w.root.children[3].items, ["A", "B"]);
|
||||
assert_eq!(w.root.children[3].selection, [1]);
|
||||
assert!(w.root.children[4].children[0].checked);
|
||||
assert_eq!(w.root.children[4].children[1].children[0].text, "hi");
|
||||
// Elements are named after their resource keys (agents address them by it).
|
||||
assert_eq!(w.root.children[1].name, "nm");
|
||||
s.execute("scriptui.set", json!({"widget": "nm", "value": "xyz"})).unwrap();
|
||||
let r = s.execute("scriptui.click", json!({"widget": "okb"})).unwrap();
|
||||
assert_eq!(r["output"], "ok xyz 4", "{r}");
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
// Syntax errors say where.
|
||||
let o = run_code(&mut s, "new Window(\"dialog { text: 'x' oops }\")", "bad.jsx");
|
||||
assert!(o.error.unwrap().message.contains("resource string"));
|
||||
let o = run_code(&mut s, "new Window(\"panel { }\")", "bad.jsx");
|
||||
assert!(o.error.unwrap().message.contains("Bad window type"));
|
||||
}
|
||||
|
||||
const DRAW: &str = r#"
|
||||
var w = new Window("palette", "Draw");
|
||||
var c = w.add("group");
|
||||
c.preferredSize = [100, 50];
|
||||
c.onDraw = function () {
|
||||
var g = this.graphics;
|
||||
var b = g.newBrush(g.BrushType.SOLID_COLOR, [1, 0, 0, 1]);
|
||||
g.newPath();
|
||||
g.rectPath(0, 0, this.size.width, this.size.height);
|
||||
g.fillPath(b);
|
||||
var p = g.newPen(g.PenType.SOLID_COLOR, [0, 0, 1], 2);
|
||||
g.newPath();
|
||||
g.moveTo(0, 0);
|
||||
g.lineTo(10, 10);
|
||||
g.strokePath(p);
|
||||
g.ellipsePath(20, 20, 10, 10);
|
||||
g.drawString("Hi", p, 5, 6, ScriptUI.newFont("dialog", "BOLD", 20));
|
||||
};
|
||||
var m = c.graphics.measureString("Hello", ScriptUI.newFont("dialog", "REGULAR", 12));
|
||||
w.show();
|
||||
[m.width, m[1]].join(",")
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn on_draw_handlers_record_a_draw_list() {
|
||||
let mut s = session();
|
||||
let o = run_code(&mut s, DRAW, "draw.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!("35,15"));
|
||||
let w = s.script_ui.windows[0].clone();
|
||||
let g = &w.root.children[0];
|
||||
assert!(g.handlers.contains(&"onDraw".to_string()));
|
||||
assert_eq!(g.draw.len(), 3, "{:?}", g.draw);
|
||||
// The handler saw the laid-out size.
|
||||
assert_eq!(g.draw[0], DrawOp::Fill { color: Some([1.0, 0.0, 0.0, 1.0]), path: vec![PathSeg::R { x: 0.0, y: 0.0, w: 100.0, h: 50.0 }] });
|
||||
match &g.draw[1] {
|
||||
DrawOp::Stroke { color, width, path } => {
|
||||
assert_eq!((*color, *width), (Some([0.0, 0.0, 1.0, 1.0]), 2.0));
|
||||
assert_eq!(path.len(), 2);
|
||||
}
|
||||
o => panic!("{o:?}"),
|
||||
}
|
||||
assert!(matches!(&g.draw[2], DrawOp::Text { text, size, style, .. } if text == "Hi" && *size == 20.0 && style == "BOLD"));
|
||||
// scriptui.get carries it for agents.
|
||||
let tree = s.execute("scriptui.get", json!({})).unwrap();
|
||||
assert_eq!(tree["root"]["children"][0]["draw"][0]["op"], "fill");
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
const LIVE: &str = r#"
|
||||
var w = new Window("palette", "Live");
|
||||
var e = w.add("edittext", undefined, "");
|
||||
var sl = w.add("slider", undefined, 0, 0, 100);
|
||||
var log = w.add("statictext", undefined, "-");
|
||||
e.onChanging = function () { writeLn("changing " + e.text); log.text = "typing " + e.text; };
|
||||
e.onChange = function () { writeLn("change " + e.text); };
|
||||
sl.onChanging = function () { writeLn("slide " + Math.round(sl.value)); };
|
||||
sl.onChange = function () { writeLn("slid " + Math.round(sl.value)); };
|
||||
w.show();
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn on_changing_fires_per_keystroke_and_slider_step() {
|
||||
let mut s = session();
|
||||
let o = run_code(&mut s, LIVE, "live.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "a", "changing": true})).unwrap();
|
||||
assert_eq!(r["output"], "changing a");
|
||||
let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "ab", "changing": true})).unwrap();
|
||||
assert_eq!(r["output"], "changing ab");
|
||||
assert_eq!(s.script_ui.windows[0].root.children[2].text, "typing ab");
|
||||
// Committing the same text runs onChange only.
|
||||
let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "ab"})).unwrap();
|
||||
assert_eq!(r["output"], "change ab");
|
||||
// A committed edit without live updates runs both.
|
||||
let r = s.execute("scriptui.set", json!({"widget": "#1", "value": "abc"})).unwrap();
|
||||
assert_eq!(r["output"], "changing abc\nchange abc");
|
||||
// Slider drags update the value live.
|
||||
let r = s.execute("scriptui.set", json!({"widget": "#2", "value": 40, "changing": true})).unwrap();
|
||||
assert_eq!(r["output"], "slide 40");
|
||||
assert_eq!(s.script_ui.windows[0].root.children[1].value, 40.0);
|
||||
let r = s.execute("scriptui.set", json!({"widget": "#2", "value": 41})).unwrap();
|
||||
assert_eq!(r["output"], "slide 41\nslid 41");
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
const MOGRT: &str = r#"
|
||||
var c = app.project.activeItem;
|
||||
var t = c.layer(1);
|
||||
var st = t.property("ADBE Text Properties").property("ADBE Text Document");
|
||||
var op = t.property("ADBE Transform Group").property("ADBE Opacity");
|
||||
var r = [st.canAddToMotionGraphicsTemplate(c), st.addToMotionGraphicsTemplate(c), op.addToMotionGraphicsTemplateAs(c, "Fade")];
|
||||
c.motionGraphicsTemplateName = "Lower Third";
|
||||
c.setMotionGraphicsControllerName(1, "Title");
|
||||
r.concat([c.motionGraphicsTemplateName, c.motionGraphicsTemplateControllerCount, c.getMotionGraphicsTemplateControllerName(1), c.getMotionGraphicsTemplateControllerName(2)]).join("|")
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn essential_graphics_scripting_hooks() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
s.execute("layer.newText", json!({"text": "Hi"})).unwrap();
|
||||
let o = run_code(&mut s, MOGRT, "mogrt.jsx");
|
||||
assert!(o.error.is_none(), "{:?} {:?}", o.error, o.output);
|
||||
assert_eq!(o.result, json!("true|true|true|Lower Third|2|Title|Fade"));
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
assert_eq!(s.project.comp(cid).unwrap().essential.as_ref().unwrap().name, "Lower Third");
|
||||
// Another comp can't take this comp's properties.
|
||||
s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64, "duration": 1})).unwrap();
|
||||
let code = "var card = null; for (var i = 1; i <= app.project.numItems; i++) if (app.project.item(i).name === 'Card') card = app.project.item(i); var o = app.project.activeItem; var p = card.layer(1).property('ADBE Transform Group').property('ADBE Position'); [p.canAddToMotionGraphicsTemplate(o), p.canAddToMotionGraphicsTemplate(card)].join('|')";
|
||||
let o = run_code(&mut s, code, "x.jsx");
|
||||
assert_eq!(o.result, json!("false|true"), "{:?}", o.error);
|
||||
// Export (behind the file-write preference).
|
||||
let dir = std::env::temp_dir().join(format!("ec-mogrt-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("lower.ectemplate");
|
||||
let export = format!(
|
||||
"var card = null; for (var i = 1; i <= app.project.numItems; i++) if (app.project.item(i).name === 'Card') card = app.project.item(i); card.exportAsMotionGraphicsTemplate(false, {:?})",
|
||||
path.to_string_lossy()
|
||||
);
|
||||
assert!(run_code(&mut s, &export, "x.jsx").error.is_some());
|
||||
s.prefs.scripting.allow_scripts_write_files = true;
|
||||
assert_eq!(run_code(&mut s, &export, "x.jsx").result, json!(true));
|
||||
assert!(std::fs::metadata(&path).unwrap().len() > 100);
|
||||
// It exists now: no overwrite → false.
|
||||
assert_eq!(run_code(&mut s, &export, "x.jsx").result, json!(false));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_nulls_from_paths_expressions_evaluate() {
|
||||
let mut s = session();
|
||||
s.execute("comp.new", json!({"name": "Paths", "width": 100, "height": 80, "frameRate": 10, "duration": 1})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#808080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true})).unwrap();
|
||||
s.execute("paths.nullsFollowPoints", json!({"layer": l})).unwrap();
|
||||
s.execute("paths.tracePath", json!({"layer": l})).unwrap();
|
||||
// Layer 1: the trace null; layers 2..5: the vertex nulls (top = last vertex).
|
||||
let pos = |i: u32, t: f64| format!("app.project.activeItem.layer({i}).property('ADBE Transform Group').property('ADBE Position').valueAtTime({t}, false)");
|
||||
let o = run_code(&mut s, &format!("[{}, {}, {}].join('|')", pos(2, 0.0), pos(1, 0.0), pos(1, 0.5)), "x.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
let parts: Vec<Vec<f64>> = o.result.as_str().unwrap().split('|').map(|p| p.split(',').map(|x| x.parse().unwrap()).collect()).collect();
|
||||
assert_eq!(&parts[0][..2], &[30.0, 60.0], "vertex 4 (0, 40) in comp space");
|
||||
assert_eq!(&parts[1][..2], &[30.0, 20.0], "progress 0: the first vertex");
|
||||
// Halfway in time along the closed square: the opposite corner.
|
||||
assert!((parts[2][0] - 70.0).abs() < 0.5 && (parts[2][1] - 60.0).abs() < 0.5, "{:?}", parts[2]);
|
||||
// Points Follow Nulls: moving the last vertex's null moves the vertex.
|
||||
let r = s.execute("paths.pointsFollowNulls", json!({"layer": l})).unwrap();
|
||||
let last = r["nulls"][3].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": last, "path": "transform/position", "value": [10, 10]})).unwrap();
|
||||
let code = "var c = app.project.activeItem; var m = c.layer(c.numLayers).property('ADBE Mask Parade').property(1).property('ADBE Mask Shape').valueAtTime(0, false); [m.vertices[3][0], m.vertices[3][1], m.vertices[0][0], m.vertices.length].join(',')";
|
||||
let o = run_code(&mut s, code, "x.jsx");
|
||||
assert_eq!(o.result, json!("-20,-10,0,4"), "{:?}", o.error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sockets_talk_tcp_behind_the_network_preference() {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
let mut s = session();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut c, _) = listener.accept().unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(c.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
c.write_all(format!("pong:{}\nrest", line.trim()).as_bytes()).unwrap();
|
||||
});
|
||||
let code = format!(
|
||||
"var s = new Socket(); var ok = s.open('127.0.0.1:{port}'); s.writeln('ping'); var r = s.readln(); var rest = s.read(); var e = s.eof; s.close(); [ok, r, rest, e, s.connected].join('|')"
|
||||
);
|
||||
// Off: no network.
|
||||
let o = run_code(&mut s, &code, "net.jsx");
|
||||
assert!(o.error.unwrap().message.contains("Access Network"));
|
||||
s.prefs.scripting.allow_scripts_write_files = true;
|
||||
let o = run_code(&mut s, &code, "net.jsx");
|
||||
assert!(o.error.is_none(), "{:?}", o.error);
|
||||
assert_eq!(o.result, json!("true|pong:ping|rest|true|false"));
|
||||
server.join().unwrap();
|
||||
// Refused connections return false with an error; listen + poll serve clients.
|
||||
let o = run_code(&mut s, "var s = new Socket(); s.timeout = 1; var ok = s.open('127.0.0.1:1'); [ok, s.error.length > 0].join('|')", "net.jsx");
|
||||
assert_eq!(o.result, json!("false|true"));
|
||||
let o = run_code(&mut s, "var l = new Socket(); l.listen(0); var r = [l.port > 0, l.poll() === null].join('|'); l.close(); r", "net.jsx");
|
||||
assert_eq!(o.result, json!("true|true"));
|
||||
}
|
||||
|
|
@ -44,10 +44,14 @@ pub enum PanelKind {
|
|||
Metadata,
|
||||
Progress,
|
||||
ContentAwareFill,
|
||||
/// Window ▸ Create Nulls From Paths (our own panel).
|
||||
CreateNullsFromPaths,
|
||||
/// Window ▸ VR Comp Editor (our own panel).
|
||||
VrCompEditor,
|
||||
}
|
||||
|
||||
impl PanelKind {
|
||||
pub const ALL: [PanelKind; 32] = [
|
||||
pub const ALL: [PanelKind; 34] = [
|
||||
PanelKind::Project,
|
||||
PanelKind::EffectControls,
|
||||
PanelKind::Composition,
|
||||
|
|
@ -80,6 +84,8 @@ impl PanelKind {
|
|||
PanelKind::Metadata,
|
||||
PanelKind::Progress,
|
||||
PanelKind::ContentAwareFill,
|
||||
PanelKind::CreateNullsFromPaths,
|
||||
PanelKind::VrCompEditor,
|
||||
];
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
|
|
@ -116,6 +122,8 @@ impl PanelKind {
|
|||
PanelKind::Metadata => "Metadata",
|
||||
PanelKind::Progress => "Progress",
|
||||
PanelKind::ContentAwareFill => "Content-Aware Fill",
|
||||
PanelKind::CreateNullsFromPaths => "Create Nulls From Paths",
|
||||
PanelKind::VrCompEditor => "VR Comp Editor",
|
||||
}
|
||||
}
|
||||
pub fn id(self) -> String {
|
||||
|
|
@ -342,7 +350,20 @@ pub fn workspace(name: &str) -> DockNode {
|
|||
tabs(&[Composition, Layer, Footage], 0),
|
||||
right(
|
||||
&[Info, Preview, Audio, Align, Character, Paragraph, LumetriScopes],
|
||||
&[EffectsPresets, Properties, Tracker, Wiggler, Smoother, MotionSketch, Paint, Brushes, Markers, ContentAwareFill],
|
||||
&[
|
||||
EffectsPresets,
|
||||
Properties,
|
||||
Tracker,
|
||||
Wiggler,
|
||||
Smoother,
|
||||
MotionSketch,
|
||||
Paint,
|
||||
Brushes,
|
||||
Markers,
|
||||
ContentAwareFill,
|
||||
CreateNullsFromPaths,
|
||||
VrCompEditor,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1094,6 +1094,23 @@ impl EffectcraftApp {
|
|||
if self.session.prefs.auto_save.enabled {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(30));
|
||||
}
|
||||
// File ▸ Watch Folder: look for new projects every 10 seconds.
|
||||
if self.session.state.watch_folder.is_some() {
|
||||
let key = egui::Id::new("watchFolder.lastPoll");
|
||||
let last: f64 = ctx.data(|d| d.get_temp(key)).unwrap_or(0.0);
|
||||
if now - last >= 10.0 {
|
||||
ctx.data_mut(|d| d.insert_temp(key, now));
|
||||
match self.session.execute("file.watchFolder.poll", serde_json::json!({})) {
|
||||
Ok(r) => {
|
||||
if let Some(n) = r["rendered"].as_array().map(Vec::len).filter(|n| *n > 0) {
|
||||
self.ui.status = format!("Watch Folder: rendered {n} project(s)");
|
||||
}
|
||||
}
|
||||
Err(e) => self.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call
|
||||
|
|
|
|||
|
|
@ -818,6 +818,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
|||
crate::panels::dialogs::open_form(app, "file.interpretFootage", &json!({}));
|
||||
Value::Null
|
||||
}
|
||||
// Help ▸ In-App Tutorials: the Home screen's Learn tab.
|
||||
"help.inAppTutorials" => {
|
||||
app.ui.start_screen = true;
|
||||
app.ui.home_learn = true;
|
||||
Value::Null
|
||||
}
|
||||
"effect.manage" | "anim.browsePresets" => {
|
||||
app.show_panel(PanelKind::EffectsPresets);
|
||||
Value::Null
|
||||
|
|
@ -848,6 +854,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
|||
"file.save" if app.session.path.is_none() => ("path", Ask::Save("Untitled Project.ecproj")),
|
||||
"file.saveAs" | "file.saveCopy" => ("path", Ask::Save("Untitled Project.ecproj")),
|
||||
"comp.saveFrameAs" => ("path", Ask::Save("Frame.png")),
|
||||
"comp.saveFrameAsPsd" => ("path", Ask::Save("Frame.psd")),
|
||||
"comp.saveFrameAsExr" => ("path", Ask::Save("Frame.exr")),
|
||||
"file.watchFolder" if params.get("stop").is_none() => ("folder", Ask::Save("Watch Folder")),
|
||||
"anim.savePreset" => ("path", Ask::Save("Preset.ecpreset")),
|
||||
"anim.applyPreset" => ("path", Ask::Open(&["ecpreset", "json"])),
|
||||
"view.exportGuides" => ("path", Ask::Save("Guides.json")),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
//! be exposed) and the template name.
|
||||
//! - The controls: drag a property name from the timeline onto the list (or Animation ▸ Add
|
||||
//! Property to Essential Graphics); each shows as a text / colour / slider / checkbox / point /
|
||||
//! angle / dropdown control editing the source property; rename by double-clicking the name;
|
||||
//! angle / dropdown control editing the source property (Add Selected As: Source Text as a font
|
||||
//! control, Scale as one uniform slider); rename by double-clicking the name;
|
||||
//! reorder with ▲▼, remove with ✕; groups and comments from Add Formatting; Media
|
||||
//! Replacement for the selected footage layer.
|
||||
//! - **Export Template…** writes an open `.ectemplate`.
|
||||
|
|
@ -134,6 +135,19 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) {
|
|||
}
|
||||
});
|
||||
app.auto.add("essential.addFormatting", r.response.rect, "Add Formatting");
|
||||
// The selected property as a Font control (Source Text) or a uniform Scale slider.
|
||||
let has_sel = !app.session.state.selected_props.is_empty();
|
||||
let r = ui.add_enabled_ui(has_sel, |ui| {
|
||||
ui.menu_button("Add Selected As", |ui| {
|
||||
for (label, as_type) in [("Font Control", "font"), ("Uniform Scale", "scale")] {
|
||||
if ui.button(label).clicked() {
|
||||
actions.push(("essential.addProperty".into(), json!({"as": as_type})));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
app.auto.add("essential.addAs", r.response.rect, "Add Selected As");
|
||||
let footage_layer = app.session.active_comp().and_then(|c| {
|
||||
app.session.state.selected_layers.iter().filter_map(|l| c.layer(*l)).find(|l| matches!(l.source, LayerSource::Footage { .. })).map(|l| l.id.0)
|
||||
});
|
||||
|
|
@ -184,7 +198,7 @@ fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[Eg
|
|||
match src {
|
||||
Some((layer, lt, p)) => {
|
||||
let v = p.value_at(lt);
|
||||
if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), &p, &v) {
|
||||
if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), essential::effective_type(c, &p), &p, &v) {
|
||||
actions.push((
|
||||
"prop.set".into(),
|
||||
json!({"comp": cid.0, "layer": layer, "prop": p.uid, "value": nv, "merge": format!("eg-{}", c.id)}),
|
||||
|
|
@ -245,8 +259,8 @@ fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgCo
|
|||
}
|
||||
|
||||
/// The editor for a control's value; returns the new value (JSON for `prop.set`).
|
||||
fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Property, v: &KV) -> Option<Value> {
|
||||
let ty = essential::control_type(p)?;
|
||||
fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Option<ControlType>, p: &Property, v: &KV) -> Option<Value> {
|
||||
let ty = ty?;
|
||||
let mut out = None;
|
||||
let rect;
|
||||
match ty {
|
||||
|
|
@ -319,6 +333,48 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, p: &Pro
|
|||
}
|
||||
rect = r.rect;
|
||||
}
|
||||
ControlType::Font => {
|
||||
// Family (from the installed fonts), style and size; the text stays as it is.
|
||||
let KV::Text(doc) = v else { return None };
|
||||
let fams = effectcraft_engine::text::fonts::families();
|
||||
let mut d = (**doc).clone();
|
||||
let mut changed = false;
|
||||
let r1 = egui::ComboBox::from_id_salt(("eg-font", auto)).selected_text(&d.font).width(110.0).show_ui(ui, |ui| {
|
||||
for (f, _) in &fams {
|
||||
if ui.selectable_label(*f == d.font, f).clicked() {
|
||||
d.font = f.clone();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
let styles: Vec<String> = fams.iter().find(|(f, _)| *f == d.font).map(|(_, st)| st.clone()).unwrap_or_default();
|
||||
let r2 = egui::ComboBox::from_id_salt(("eg-style", auto)).selected_text(&d.style).width(70.0).show_ui(ui, |ui| {
|
||||
for st in &styles {
|
||||
if ui.selectable_label(*st == d.style, st).clicked() {
|
||||
d.style = st.clone();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
let r3 = ui.add(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1));
|
||||
app.auto.add(&format!("{auto}.font"), r1.response.rect, "Font");
|
||||
app.auto.add(&format!("{auto}.style"), r2.response.rect, "Style");
|
||||
app.auto.add(&format!("{auto}.size"), r3.rect, "Size");
|
||||
if changed || r3.changed() {
|
||||
out = serde_json::to_value(&d).ok();
|
||||
}
|
||||
rect = r1.response.rect.union(r3.rect);
|
||||
}
|
||||
ControlType::Scale => {
|
||||
// One percentage for every axis.
|
||||
let comps = v.components();
|
||||
let mut x = comps.first().copied().unwrap_or(100.0);
|
||||
let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1));
|
||||
if r.changed() {
|
||||
out = Some(json!(vec![x; comps.len().max(1)]));
|
||||
}
|
||||
rect = r.rect;
|
||||
}
|
||||
ControlType::Slider | ControlType::Media => {
|
||||
let mut x = v.as_f64();
|
||||
let r = match p.ui {
|
||||
|
|
@ -389,7 +445,7 @@ fn instance(app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Actions)
|
|||
}
|
||||
_ => {
|
||||
let v = p.value_at(lt);
|
||||
if let Some(nv) = value_widget(app, ui, &auto, &p, &v) {
|
||||
if let Some(nv) = value_widget(app, ui, &auto, essential::effective_type(c, &p), &p, &v) {
|
||||
actions.push((
|
||||
"essential.set".into(),
|
||||
json!({"layer": layer.id.0, "control": c.id, "value": nv, "merge": format!("egi-{}-{}", layer.id.0, c.id)}),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ pub mod media_panels;
|
|||
pub mod misc;
|
||||
pub mod paint_panels;
|
||||
pub mod panel_kit;
|
||||
pub mod path_vr_panels;
|
||||
pub mod precomp;
|
||||
pub mod project;
|
||||
pub mod properties;
|
||||
|
|
@ -117,6 +118,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
|
|||
PanelKind::Metadata => media_panels::metadata(app, ui, rect),
|
||||
PanelKind::Progress => media_panels::progress(app, ui, rect),
|
||||
PanelKind::ContentAwareFill => content_fill_panel::show(app, ui, rect),
|
||||
PanelKind::CreateNullsFromPaths => path_vr_panels::create_nulls(app, ui, rect),
|
||||
PanelKind::VrCompEditor => path_vr_panels::vr_editor(app, ui, rect),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
//! Two built-in panels After Effects ships as scripts, written from scratch as panels:
|
||||
//!
|
||||
//! - **Create Nulls From Paths** (Window ▸ Create Nulls From Paths): with a mask or shape path (or
|
||||
//! a layer that has one) selected, Points Follow Nulls / Nulls Follow Points / Trace Path
|
||||
//! (`paths.*` commands; Trace Path can loop).
|
||||
//! - **VR Comp Editor** (Window ▸ VR Comp Editor): the project's VR environments (Composition ▸ VR
|
||||
//! ▸ Create VR Environment); pick one and edit its 360 view's camera orientation (pan, tilt,
|
||||
//! roll) with `comp.vr.setView`; open its output.
|
||||
//!
|
||||
//! Automation ids: `createNulls.pointsFollowNulls`, `createNulls.nullsFollowPoints`,
|
||||
//! `createNulls.tracePath`, `createNulls.loop`, `vrEditor.env.<output id>`, `vrEditor.pan`,
|
||||
//! `vrEditor.tilt`, `vrEditor.roll`, `vrEditor.reset`, `vrEditor.open`.
|
||||
|
||||
use egui::{Rect, RichText};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::EffectcraftApp;
|
||||
|
||||
fn run(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, Value)>) {
|
||||
for (id, p) in actions {
|
||||
match crate::menus::invoke(app, ctx, id, p) {
|
||||
Ok(_) => {}
|
||||
Err(e) => app.ui.status = e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn area(ui: &mut egui::Ui, rect: Rect) -> egui::Ui {
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(10.0)).layout(egui::Layout::top_down(egui::Align::Min)))
|
||||
}
|
||||
|
||||
/// Window ▸ Create Nulls From Paths.
|
||||
pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut ui = area(ui, rect);
|
||||
let loop_id = egui::Id::new("createNulls.loop");
|
||||
let mut looping: bool = ui.data(|d| d.get_temp(loop_id)).unwrap_or(false);
|
||||
let ready = app.session.is_enabled("paths.nullsFollowPoints") && !app.session.state.selected_layers.is_empty();
|
||||
ui.label(RichText::new("Select a layer with a mask or shape path (or the path property).").color(t.text_dim).small());
|
||||
ui.add_space(6.0);
|
||||
let mut acts = vec![];
|
||||
for (id, label, tip) in [
|
||||
("paths.pointsFollowNulls", "Points Follow Nulls", "A null on each vertex; move the nulls to reshape the path"),
|
||||
("paths.nullsFollowPoints", "Nulls Follow Points", "A null on each vertex that follows the (animated) path"),
|
||||
("paths.tracePath", "Trace Path", "A null moving along the path, driven by its Progress slider"),
|
||||
] {
|
||||
let r = ui.add_enabled(ready, egui::Button::new(label).min_size(egui::vec2(170.0, 24.0))).on_hover_text(tip);
|
||||
let auto = format!("createNulls.{}", id.trim_start_matches("paths."));
|
||||
app.auto.add(&auto, r.rect, label);
|
||||
if r.clicked() {
|
||||
acts.push((id, if id == "paths.tracePath" { json!({"loop": looping}) } else { json!({}) }));
|
||||
}
|
||||
}
|
||||
let r = ui.checkbox(&mut looping, "Loop (Trace Path)");
|
||||
app.auto.add("createNulls.loop", r.rect, "Loop");
|
||||
ui.data_mut(|d| d.insert_temp(loop_id, looping));
|
||||
run(app, &ctx, acts);
|
||||
}
|
||||
|
||||
/// Window ▸ VR Comp Editor.
|
||||
pub fn vr_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut ui = area(ui, rect);
|
||||
let envs = app.session.execute("comp.vr.environments", json!({})).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default();
|
||||
if envs.is_empty() {
|
||||
ui.label(RichText::new("No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…").color(t.text_faint));
|
||||
return;
|
||||
}
|
||||
let sel_id = egui::Id::new("vrEditor.selected");
|
||||
let active = app.session.state.active_comp.map(|c| c.0);
|
||||
let mut sel: u64 = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0);
|
||||
// Default: the active comp's environment, else the first.
|
||||
let owns = |e: &Value, c: u64| e["output"] == c || e["cubeMap"] == c || e["faces"].as_array().is_some_and(|f| f.iter().any(|x| x["comp"] == c));
|
||||
if !envs.iter().any(|e| e["output"] == sel) {
|
||||
sel = active.and_then(|c| envs.iter().find(|e| owns(e, c))).or(envs.first()).and_then(|e| e["output"].as_u64()).unwrap_or(0);
|
||||
}
|
||||
let mut acts = vec![];
|
||||
ui.label(RichText::new("VR Environments").strong());
|
||||
for e in &envs {
|
||||
let out = e["output"].as_u64().unwrap_or(0);
|
||||
let r = ui.selectable_label(out == sel, e["name"].as_str().unwrap_or(""));
|
||||
app.auto.add(&format!("vrEditor.env.{out}"), r.rect, e["name"].as_str().unwrap_or(""));
|
||||
if r.clicked() {
|
||||
sel = out;
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(sel_id, sel));
|
||||
let Some(env) = envs.iter().find(|e| e["output"] == sel) else { return };
|
||||
ui.separator();
|
||||
ui.label(RichText::new("360 View Orientation").strong());
|
||||
let view: Vec<f64> = env["view"].as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let signed = |a: f64| if a > 180.0 { a - 360.0 } else { a };
|
||||
let (mut tilt, mut pan, mut roll) =
|
||||
(signed(view.first().copied().unwrap_or(0.0)), signed(view.get(1).copied().unwrap_or(0.0)), signed(view.get(2).copied().unwrap_or(0.0)));
|
||||
let mut changed = false;
|
||||
for (label, auto, v) in [("Pan", "vrEditor.pan", &mut pan), ("Tilt", "vrEditor.tilt", &mut tilt), ("Roll", "vrEditor.roll", &mut roll)] {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
let r = ui.add(egui::Slider::new(v, -180.0..=180.0).suffix("°").max_decimals(1));
|
||||
app.auto.add(auto, r.rect, label);
|
||||
changed |= r.changed();
|
||||
});
|
||||
}
|
||||
if changed {
|
||||
acts.push(("comp.vr.setView", json!({"comp": sel, "pan": pan, "tilt": tilt, "roll": roll})));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.button("Reset");
|
||||
app.auto.add("vrEditor.reset", r.rect, "Reset");
|
||||
if r.clicked() {
|
||||
acts.push(("comp.vr.setView", json!({"comp": sel, "orientation": [0.0, 0.0, 0.0]})));
|
||||
}
|
||||
let r = ui.button("Open Output");
|
||||
app.auto.add("vrEditor.open", r.rect, "Open Output");
|
||||
if r.clicked() {
|
||||
acts.push(("comp.open", json!({"comp": sel})));
|
||||
}
|
||||
});
|
||||
ui.label(RichText::new("All six face cameras turn together; the scene is not changed.").color(t.text_faint).small());
|
||||
run(app, &ctx, acts);
|
||||
}
|
||||
|
|
@ -3,9 +3,10 @@
|
|||
//! computed; what the user does goes back through the `scriptui.click` / `scriptui.set` /
|
||||
//! `scriptui.close` commands, which run the script's handlers. Every control registers an
|
||||
//! automation id `scriptui.<window>.<control>` (and `scriptui.<window>.<name>` when the script
|
||||
//! named it).
|
||||
//! named it). Controls with an `onDraw` handler are painted from their draw list; edit texts and
|
||||
//! sliders send live `changing` updates (onChanging) while typing / dragging.
|
||||
|
||||
use effectcraft_engine::scriptui::{ScriptWindow, Widget, WidgetKind, WindowKind, layout};
|
||||
use effectcraft_engine::scriptui::{DrawOp, PathSeg, ScriptWindow, Widget, WidgetKind, WindowKind, layout};
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, UiBuilder, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -16,6 +17,8 @@ use crate::theme::Tokens;
|
|||
enum Act {
|
||||
Click(u32, u32),
|
||||
Set(u32, u32, Value),
|
||||
/// A live update (each keystroke / slider step): fires onChanging.
|
||||
Changing(u32, u32, Value),
|
||||
Close(u32),
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +81,27 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2,
|
|||
register(app, win, w, rect);
|
||||
let id = egui::Id::new(("sui", win, w.id));
|
||||
let tip = |r: egui::Response| if w.help_tip.is_empty() { r } else { r.on_hover_text(&w.help_tip) };
|
||||
// onDraw: the script paints the control. Without drawOSControl() its paint replaces the
|
||||
// default look (buttons and toggles still respond to clicks); with it, it goes on top.
|
||||
let custom = w.handlers.iter().any(|h| h == "onDraw");
|
||||
let os = w.draw.iter().any(|d| matches!(d, DrawOp::Os));
|
||||
if custom && !os {
|
||||
if w.kind.is_container() {
|
||||
paint_draw(ui, rect, &w.draw, &t);
|
||||
draw_children(app, ui, origin, win, w, acts);
|
||||
return;
|
||||
}
|
||||
let clickable = matches!(w.kind, WidgetKind::Button | WidgetKind::IconButton | WidgetKind::Checkbox | WidgetKind::RadioButton);
|
||||
let r = ui.interact(rect, id, if clickable && w.enabled { Sense::click() } else { Sense::hover() });
|
||||
paint_draw(ui, rect, &w.draw, &t);
|
||||
if tip(r).clicked() && clickable {
|
||||
acts.push(Act::Click(win, w.id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if custom && w.kind.is_container() {
|
||||
paint_draw(ui, rect, &w.draw, &t);
|
||||
}
|
||||
match w.kind {
|
||||
WidgetKind::Group | WidgetKind::Tab | WidgetKind::Window | WidgetKind::TabbedPanel => draw_children(app, ui, origin, win, w, acts),
|
||||
WidgetKind::Panel => {
|
||||
|
|
@ -112,8 +136,16 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2,
|
|||
let te = te.id(id).interactive(!w.read_only).font(Tokens::ui(12.0));
|
||||
let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, te)).inner;
|
||||
let r = tip(r);
|
||||
if r.lost_focus() && buf != w.text {
|
||||
// Live updates already sent the text: the commit still runs onChange.
|
||||
let dirty_id = id.with("dirty");
|
||||
let dirty = ui.data(|d| d.get_temp::<bool>(dirty_id)).unwrap_or(false);
|
||||
if r.lost_focus() && (buf != w.text || dirty) {
|
||||
acts.push(Act::Set(win, w.id, json!(buf)));
|
||||
ui.data_mut(|d| d.remove::<bool>(dirty_id));
|
||||
} else if r.changed() && w.handlers.iter().any(|h| h == "onChanging") {
|
||||
// Each keystroke reaches the script's onChanging (onChange runs on commit).
|
||||
acts.push(Act::Changing(win, w.id, json!(buf)));
|
||||
ui.data_mut(|d| d.insert_temp(dirty_id, true));
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, buf));
|
||||
}
|
||||
|
|
@ -145,6 +177,10 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2,
|
|||
.inner;
|
||||
let r = tip(r);
|
||||
if r.dragged() {
|
||||
// Live: each step of the drag reaches the script (onChanging).
|
||||
if r.changed() {
|
||||
acts.push(Act::Changing(win, w.id, json!(v)));
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<f64>(id));
|
||||
|
|
@ -203,6 +239,45 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2,
|
|||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
if custom && !w.kind.is_container() {
|
||||
paint_draw(ui, rect, &w.draw, &t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint an onDraw draw list in `rect` (control coordinates → screen).
|
||||
fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) {
|
||||
let p = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
|
||||
let col = |c: &Option<[f32; 4]>, theme: Color32| match c {
|
||||
Some(c) => egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]).into(),
|
||||
None => theme,
|
||||
};
|
||||
let at = |x: f64, y: f64| rect.min + vec2(x as f32, y as f32);
|
||||
for op in ops {
|
||||
match op {
|
||||
DrawOp::Fill { color, path } => {
|
||||
let c = col(color, t.field_bg);
|
||||
for (pts, _) in PathSeg::polylines(path, 48) {
|
||||
let pts: Vec<egui::Pos2> = pts.iter().map(|q| at(q[0], q[1])).collect();
|
||||
p.add(egui::Shape::convex_polygon(pts, c, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
DrawOp::Stroke { color, width, path } => {
|
||||
let s = Stroke::new(*width as f32, col(color, t.text));
|
||||
for (pts, closed) in PathSeg::polylines(path, 48) {
|
||||
let pts: Vec<egui::Pos2> = pts.iter().map(|q| at(q[0], q[1])).collect();
|
||||
p.add(if closed { egui::Shape::closed_line(pts, s) } else { egui::Shape::line(pts, s) });
|
||||
}
|
||||
}
|
||||
DrawOp::Text { text, color, x, y, size, .. } => {
|
||||
p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), col(color, t.text));
|
||||
}
|
||||
DrawOp::Image { x, y, w, h } => {
|
||||
let r = Rect::from_min_size(at(*x, *y), vec2(*w as f32, *h as f32));
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
}
|
||||
DrawOp::Os => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checkboxes and radio buttons sit at the left of their (possibly filled) bounds.
|
||||
|
|
@ -216,6 +291,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec<Act>) {
|
|||
let (cmd, p) = match a {
|
||||
Act::Click(w, id) => ("scriptui.click", json!({"window": w, "widget": id})),
|
||||
Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})),
|
||||
Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})),
|
||||
Act::Close(w) => ("scriptui.close", json!({"window": w})),
|
||||
};
|
||||
match crate::menus::invoke(app, ctx, cmd, p) {
|
||||
|
|
|
|||
|
|
@ -15,11 +15,17 @@ 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), 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.
|
||||
Partial features: GPU coverage (Advanced 3D, more GPU effects), the web app (no threads for
|
||||
viewer frames and Roto Brush), Essential Graphics mirrored properties, and the learned-model quality
|
||||
of Roto Brush and face tracking (both are classical). Since this audit, M9.13 completed all 42
|
||||
simplified effects, M13.5 added the Learn panel, and M13.8 the Preview panel settings, Align to
|
||||
Selection / Distribute and Advanced 3D iris DOF, collapsed precomps and extruded strokes. M13.7 added ScriptUI resource strings,
|
||||
`onDraw` (ScriptUIGraphics draw lists), live `onChanging`, `Socket`, the Essential Graphics
|
||||
scripting hooks and Font / uniform Scale controls, and the last missing menu items: Save Frame As ▸
|
||||
Photoshop Layers / ProEXR, Composition ▸ Open in Essential Graphics, File ▸ Watch Folder,
|
||||
Window ▸ Create Nulls From Paths / VR Comp Editor (built-in panels) and Help ▸ In-App / Online
|
||||
Tutorials. File ▸ Import ▸ Vanishing Point (.vpe) is present but permanently disabled: the format
|
||||
has no public specification, so a clean-room implementation is not possible (its tooltip says so).
|
||||
|
||||
### Previous audit (commit `fa26ad9`, 2 October 2026, late): ≈ 93%
|
||||
|
||||
|
|
@ -123,9 +129,9 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
|
|||
| Output | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control. Left: Opus is CELT-only, Photoshop sequence output, overflow for movies only checks at file creation |
|
||||
| Audio | 85% | 0.5 | audio to keyframes |
|
||||
| Import | 96% | 0.6 | mesh shadings (types 4–7), JPX / CCITT / JBIG2 images and non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) |
|
||||
| Automation | ≈ 97% | 0.3 | `.jsxbin`, sockets, ScriptUI resource strings and custom `onDraw` graphics (the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) |
|
||||
| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) |
|
||||
| Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) |
|
||||
| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' rare controls (font menus, mirrored properties) (the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
|
||||
| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' mirrored properties (CMP-7: Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
|
||||
| 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 | ≈ 95% | 0.4 | no extruded strokes, the Variable Font Axes animator changes outlines but not advances (M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; 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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue