mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 13:37:37 -06:00
Merge main into M13.5 long-tail polish
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,574 @@
|
||||
//! 3D Camera Tracker commands: Animation ▸ Track Camera (`track.camera`), the effect's Analyze /
|
||||
//! Cancel / Create Camera buttons, the viewer's track point selection and right-click menu
|
||||
//! (Set Ground Plane and Origin, Create Text / Solid / Null / Shadow Catcher … and Camera,
|
||||
//! Create Multiple …, Delete Selected Points) and the status / points queries.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_effects::camera_tracker::{self as ct, CAMERA, DELETED, SOLVE};
|
||||
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Project, Solid, Uid};
|
||||
use effectcraft_track::camtrack::linalg::{self, V3};
|
||||
use effectcraft_track::camtrack::{ShotType, SolveMethod, Target, point_color};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, f_p, has_comp, layer_p, str_p};
|
||||
use crate::camera_track::{CreateKind, Placed, is_tracker, placed, plane_orientation, static_params, target_for, write_camera};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"track.camera",
|
||||
"Track Camera",
|
||||
["Animation"],
|
||||
None,
|
||||
"{layer?, shotType?: fixed|variable|specify, aov?: degrees, solveMethod?: auto|typical|flat|tripod, detailed?, wait?}",
|
||||
can_track,
|
||||
track_camera
|
||||
),
|
||||
cmd!("camera.analyze", "Analyze", [], None, "{layer?, effect?: uid|name|index, wait?: block until done}", has_comp, analyze),
|
||||
cmd!("camera.cancel", "Cancel", [], None, "{}", is_analyzing, |s, _| Ok(json!({"stopped": s.stop_camera()}))),
|
||||
query!("camera.solveStatus", "3D Camera Tracker Status", "{layer?, effect?}", status),
|
||||
query!("camera.points", "3D Camera Tracker Points", "{layer?, effect?, time?: comp seconds} → visible solved points", points),
|
||||
cmd!("camera.selectPoints", "Select Track Points", [], None, "{points: [id], add?, toggle?}", has_comp, select_points),
|
||||
cmd!(
|
||||
"camera.createFromSolve",
|
||||
"Create from Camera Solve",
|
||||
[],
|
||||
None,
|
||||
"{kind: text|solid|null|shadowCatcher|camera, points?: [id] (default: selected), target?: {center, normal, size?} (comp world), multiple?, layer?, effect?}",
|
||||
has_comp,
|
||||
create_from_solve
|
||||
),
|
||||
cmd!("camera.create", "Create Camera", [], None, "{layer?, effect?}", has_comp, |s, p| create_from_solve(s, &with_kind(p, "camera"))),
|
||||
cmd!("camera.setGroundPlane", "Set Ground Plane and Origin", [], None, "{points?: [id] (default: selected), layer?, effect?}", has_comp, set_ground),
|
||||
cmd!("camera.deletePoints", "Delete Selected Points", [], None, "{points?: [id] (default: selected), layer?, effect?, wait?}", has_comp, delete_points),
|
||||
]
|
||||
}
|
||||
|
||||
fn with_kind(p: &Value, kind: &str) -> Value {
|
||||
let mut q = p.clone();
|
||||
if !q.is_object() {
|
||||
q = json!({});
|
||||
}
|
||||
q["kind"] = json!(kind);
|
||||
q
|
||||
}
|
||||
|
||||
fn can_track(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
let c = s.active_comp().ok_or("no composition is open")?;
|
||||
match s.state.selected_layers.first().and_then(|l| c.layer(*l)) {
|
||||
Some(l) if l.has_video() && !l.switches.adjustment && !l.is_camera() && !l.is_light() => Ok(()),
|
||||
Some(_) => Err("Track Camera needs a footage or composition layer".into()),
|
||||
None => Err("select a layer to track".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_analyzing(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.is_camera_analyzing() { Ok(()) } else { Err("no 3D Camera Tracker analysis is running".into()) }
|
||||
}
|
||||
|
||||
/// (comp, layer, effect uid) of a 3D Camera Tracker: `layer` / `effect` (uid, name or 1-based
|
||||
/// index), else a selected one, else the first in the comp.
|
||||
fn ct_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let candidates: Vec<LayerId> = match p.get("layer") {
|
||||
Some(v) => vec![super::resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?],
|
||||
None => {
|
||||
let mut v: Vec<LayerId> = s.state.selected_props.iter().map(|(l, _)| *l).collect();
|
||||
v.extend(s.state.selected_layers.iter().copied());
|
||||
v.extend(comp.layers.iter().map(|l| l.id));
|
||||
v
|
||||
}
|
||||
};
|
||||
for lid in candidates {
|
||||
let Some(fx) = comp.layer(lid).and_then(Layer::effects) else { continue };
|
||||
let g = match p.get("effect") {
|
||||
Some(Value::Number(n)) => {
|
||||
let n = n.as_u64().unwrap_or(0);
|
||||
fx.groups().find(|g| g.uid == n).or_else(|| fx.groups().nth((n as usize).saturating_sub(1)))
|
||||
}
|
||||
Some(Value::String(name)) => fx.groups().find(|g| &g.name == name),
|
||||
_ => s
|
||||
.state
|
||||
.selected_props
|
||||
.iter()
|
||||
.find_map(|(l, u)| (*l == lid).then(|| fx.groups().find(|g| g.uid == *u && is_tracker(g))).flatten())
|
||||
.or_else(|| fx.groups().find(|g| is_tracker(g))),
|
||||
};
|
||||
if let Some(g) = g.filter(|g| is_tracker(g)) {
|
||||
return Ok((cid, lid, g.uid));
|
||||
}
|
||||
if p.get("layer").is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(bad(cmd, "no 3D Camera Tracker found (apply Animation ▸ Track Camera first)"))
|
||||
}
|
||||
|
||||
fn shot_p(p: &Value) -> Result<Option<ShotType>> {
|
||||
match p.get("shotType") {
|
||||
None => Ok(None),
|
||||
Some(Value::Number(n)) => ShotType::ALL.get(n.as_u64().unwrap_or(99) as usize).copied().map(Some).ok_or_else(|| bad("track.camera", "shotType: 0..2")),
|
||||
Some(Value::String(v)) => ShotType::from_name(v).map(Some).ok_or_else(|| bad("track.camera", "shotType: fixed|variable|specify")),
|
||||
Some(_) => Err(bad("track.camera", "shotType: fixed|variable|specify")),
|
||||
}
|
||||
}
|
||||
|
||||
fn method_p(p: &Value) -> Result<Option<SolveMethod>> {
|
||||
match p.get("solveMethod") {
|
||||
None => Ok(None),
|
||||
Some(Value::Number(n)) => {
|
||||
SolveMethod::ALL.get(n.as_u64().unwrap_or(99) as usize).copied().map(Some).ok_or_else(|| bad("track.camera", "solveMethod: 0..3"))
|
||||
}
|
||||
Some(Value::String(v)) => SolveMethod::from_name(v).map(Some).ok_or_else(|| bad("track.camera", "solveMethod: auto|typical|flat|tripod")),
|
||||
Some(_) => Err(bad("track.camera", "solveMethod: auto|typical|flat|tripod")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Animation ▸ Track Camera / the Tracker panel button: apply the 3D Camera Tracker to the layer
|
||||
/// (reusing one it already has), set the given options and start the analysis.
|
||||
fn track_camera(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "track.camera")?;
|
||||
let shot = shot_p(p)?;
|
||||
let method = method_p(p)?;
|
||||
let aov = f_p(p, "aov").or_else(|| f_p(p, "angleOfView"));
|
||||
if aov.is_some_and(|a| !(1.0..=170.0).contains(&a)) {
|
||||
return Err(bad("track.camera", "aov: 1..170 degrees"));
|
||||
}
|
||||
let detailed = b_p(p, "detailed");
|
||||
let existing = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(Layer::effects).and_then(|fx| fx.groups().find(|g| is_tracker(g)).map(|g| g.uid));
|
||||
let uid = match existing {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
let r = s.execute("effect.apply", json!({"effect": ct::ID, "layers": [lid.0], "comp": cid.0}))?;
|
||||
r["effects"].get(0).and_then(Value::as_u64).ok_or_else(|| EngineError::Other("the effect could not be applied".into()))?
|
||||
}
|
||||
};
|
||||
if shot.is_some() || method.is_some() || aov.is_some() || detailed.is_some() {
|
||||
s.edit("3D Camera Tracker Settings", None, |proj, _| {
|
||||
let g = proj
|
||||
.comp_mut(cid)
|
||||
.and_then(|c| c.layer_mut(lid))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.ok_or_else(|| EngineError::Other("no 3D Camera Tracker".into()))?;
|
||||
let mut set = |m: &str, v: KV| {
|
||||
if let Some(pr) = g.prop_mut(m) {
|
||||
pr.keys.clear();
|
||||
pr.value = v;
|
||||
}
|
||||
};
|
||||
if let Some(sh) = shot {
|
||||
set("shotType", KV::Enum(ShotType::ALL.iter().position(|x| *x == sh).unwrap_or(0) as u32));
|
||||
}
|
||||
if let Some(a) = aov {
|
||||
set("horizontalAngleOfView", KV::Scalar(a));
|
||||
if shot.is_none() {
|
||||
set("shotType", KV::Enum(2));
|
||||
}
|
||||
}
|
||||
if let Some(m) = method {
|
||||
set("advanced/solveMethod", KV::Enum(SolveMethod::ALL.iter().position(|x| *x == m).unwrap_or(0) as u32));
|
||||
}
|
||||
if let Some(d) = detailed {
|
||||
set("advanced/detailedAnalysis", KV::Bool(d));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
// Select the effect so the viewer shows its track points.
|
||||
s.state.selected_props = vec![(lid, uid)];
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
let n = s.start_camera(cid, lid, uid, wait).map_err(EngineError::Other)?;
|
||||
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_camera_analyzing(), "progress": s.camera_progress()});
|
||||
if wait {
|
||||
out["status"] = status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = ct_p(s, p, "camera.analyze")?;
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
let n = s.start_camera(cid, lid, uid, wait).map_err(EngineError::Other)?;
|
||||
let mut out = json!({"layer": lid.0, "effect": uid, "frames": n, "running": s.is_camera_analyzing(), "progress": s.camera_progress()});
|
||||
if wait {
|
||||
out["status"] = status(s, &json!({"layer": lid.0, "effect": uid, "comp": cid.0}))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.poll_camera(false);
|
||||
let mut out = json!({"running": s.is_camera_analyzing(), "progress": s.camera_progress(), "banner": s.camera_progress().map(|p| p.banner())});
|
||||
let Ok((cid, lid, uid)) = ct_p(s, p, "camera.solveStatus") else { return Ok(out) };
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let g = layer.props.find_group(uid).ok_or(EngineError::NoComp)?;
|
||||
let params = static_params(g);
|
||||
out["layer"] = json!(lid.0);
|
||||
out["effect"] = json!(uid);
|
||||
out["pending"] = json!(s.camera_pending.contains(&(cid, lid, uid)));
|
||||
let tracks = ct::tracks(¶ms);
|
||||
out["analyzed"] = json!(tracks.is_some());
|
||||
if let Some(t) = &tracks {
|
||||
out["tracks"] = json!(t.tracks.len());
|
||||
out["frames"] = json!(t.frames);
|
||||
}
|
||||
let settings = ct::settings(¶ms);
|
||||
out["shotType"] = json!(settings.shot.label());
|
||||
out["solveMethod"] = json!(settings.method.label());
|
||||
out["deleted"] = json!(settings.deleted.len());
|
||||
let solve = ct::solve(¶ms);
|
||||
out["solved"] = json!(solve.is_some());
|
||||
if let Some(sv) = &solve {
|
||||
let f = sv.focal();
|
||||
out["methodUsed"] = json!(sv.method_used.label());
|
||||
out["averageError"] = json!(sv.average_error);
|
||||
out["points"] = json!(sv.points.len());
|
||||
out["solvedFrames"] = json!(sv.frames.iter().filter(|c| c.solved).count());
|
||||
out["focalLength"] = json!(f);
|
||||
out["horizontalAngleOfView"] = json!(sv.hfov(f));
|
||||
out["groundPlane"] = json!(sv.ground.is_some());
|
||||
if let Some(pl) = placed(&s.project, cid, lid, uid) {
|
||||
let k = pl.frame_at(s.time_of(cid));
|
||||
let (pos, ori, zoom) = pl.camera(k);
|
||||
out["frame"] = json!(k);
|
||||
out["camera"] = json!({"position": pos, "orientation": ori, "zoom": zoom});
|
||||
out["visible"] = json!(sv.visible(k).count());
|
||||
}
|
||||
}
|
||||
let cam = params.get(CAMERA).map(|v| v.as_f64() as u64).unwrap_or(0);
|
||||
out["cameraLayer"] = if cam > 0 && comp.layer(LayerId(cam)).is_some_and(Layer::is_camera) { json!(cam) } else { Value::Null };
|
||||
out["selected"] = json!(s.state.camera_points);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The visible solved points at the current (or given) time, with their comp positions.
|
||||
fn points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = ct_p(s, p, "camera.points")?;
|
||||
let pl = placed(&s.project, cid, lid, uid).ok_or_else(|| bad("camera.points", "the camera is not solved yet"))?;
|
||||
let t = f_p(p, "time").map(effectcraft_time::Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
|
||||
let k = pl.frame_at(t);
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t);
|
||||
let m = ctx.world_matrix(layer);
|
||||
let list: Vec<Value> = ct::projected(&pl.solve, k)
|
||||
.into_iter()
|
||||
.map(|(id, q, z)| {
|
||||
let c = m.apply(effectcraft_geom::vec3(q[0], q[1], 0.0));
|
||||
let sp = pl.solve.point(id).copied().unwrap_or_default();
|
||||
let col = point_color(id);
|
||||
json!({"id": id, "comp": [c.x, c.y], "layer": q, "depth": z, "world": pl.to_world(sp.pos), "error": sp.error, "color": col, "selected": s.state.camera_points.contains(&id)})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"frame": k, "points": list}))
|
||||
}
|
||||
|
||||
fn ids_p(s: &Session, p: &Value) -> Vec<u32> {
|
||||
match p.get("points") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(|v| v as u32).collect(),
|
||||
Some(Value::Number(n)) => n.as_u64().map(|v| vec![v as u32]).unwrap_or_default(),
|
||||
_ => s.state.camera_points.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<u32> = match p.get("points") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(|v| v as u32).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
if b_p(p, "toggle").unwrap_or(false) {
|
||||
for id in ids {
|
||||
match s.state.camera_points.iter().position(|x| *x == id) {
|
||||
Some(i) => {
|
||||
s.state.camera_points.remove(i);
|
||||
}
|
||||
None => s.state.camera_points.push(id),
|
||||
}
|
||||
}
|
||||
} else if b_p(p, "add").unwrap_or(false) {
|
||||
for id in ids {
|
||||
if !s.state.camera_points.contains(&id) {
|
||||
s.state.camera_points.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.state.camera_points = ids;
|
||||
}
|
||||
s.bump();
|
||||
Ok(json!({"selected": s.state.camera_points}))
|
||||
}
|
||||
|
||||
/// The target of a create / ground command in canonical solve coordinates.
|
||||
fn target_p(s: &Session, p: &Value, pl: &Placed, k: usize, cmd: &str) -> Result<Target> {
|
||||
if let Some(t) = p.get("target") {
|
||||
let v3 = |k: &str| -> Option<V3> {
|
||||
let a = t.get(k)?.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)])
|
||||
};
|
||||
let (c, n) = (v3("center").ok_or_else(|| bad(cmd, "target.center: [x, y, z]"))?, v3("normal").unwrap_or([0.0, -1.0, 0.0]));
|
||||
let size = t.get("size").and_then(Value::as_f64).unwrap_or(100.0);
|
||||
let nc = linalg::normalize(linalg::mtv(&pl.world.r, n));
|
||||
return Ok(Target { center: pl.from_world(c), normal: nc, size: size / pl.world.s });
|
||||
}
|
||||
let ids = ids_p(s, p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select track points (or give `points` / `target`)"));
|
||||
}
|
||||
target_for(&pl.solve, &ids, k).ok_or_else(|| bad(cmd, "those points do not define a plane"))
|
||||
}
|
||||
|
||||
fn next_name(c: &effectcraft_project::Comp, base: &str) -> String {
|
||||
let n = (1..).find(|i| !c.layers.iter().any(|l| l.name == format!("{base} {i}"))).unwrap_or(1);
|
||||
format!("{base} {n}")
|
||||
}
|
||||
|
||||
fn place(l: &mut Layer, pos: V3, ori: V3, src: &Layer) {
|
||||
l.switches.three_d = true;
|
||||
l.in_point = src.in_point;
|
||||
l.out_point = src.out_point;
|
||||
if let Some(pr) = l.props.prop_mut("transform/position") {
|
||||
pr.value = KV::Vec3(pos);
|
||||
}
|
||||
if let Some(pr) = l.props.prop_mut("transform/orientation") {
|
||||
pr.value = KV::Vec3(ori);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `l` above the tracked layer.
|
||||
fn insert_above(proj: &mut Project, cid: ItemId, above: LayerId, l: Layer) -> Result<LayerId> {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = comp.layers.iter().position(|x| x.id == above).unwrap_or(0);
|
||||
let id = l.id;
|
||||
comp.layers.insert(at, l);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn add_solid(proj: &mut Project, comp: &effectcraft_project::Comp, name: &str, size: u32, color: [f32; 3]) -> Layer {
|
||||
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
|
||||
let sid = proj.add_item(name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: size, height: size, pixel_aspect: 1.0 }));
|
||||
build::layer(proj, comp, name, LayerSource::Solid { item: sid }, (size, size), None)
|
||||
}
|
||||
|
||||
/// Create Text / Solid / Null / Shadow Catcher (and Light) on the target, plus the 3D Tracker
|
||||
/// Camera when the tracker has none yet — one undo step.
|
||||
fn create_from_solve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "camera.createFromSolve";
|
||||
let (cid, lid, uid) = ct_p(s, p, cmd)?;
|
||||
let kind = CreateKind::from_name(str_p(p, "kind").unwrap_or("null")).ok_or_else(|| bad(cmd, "kind: text|solid|null|shadowCatcher|camera"))?;
|
||||
let pl = placed(&s.project, cid, lid, uid).ok_or_else(|| bad(cmd, "the camera is not solved yet (analysis still running or failed)"))?;
|
||||
let k = pl.frame_at(s.time_of(cid));
|
||||
let multiple = b_p(p, "multiple").unwrap_or(false);
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let src = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
|
||||
let g = src.props.find_group(uid).ok_or(EngineError::NoComp)?;
|
||||
let params = static_params(g);
|
||||
let size_pct = effectcraft_effects::warp_stab::param(¶ms, "targetSize").map(|v| v.as_f64()).unwrap_or(100.0) / 100.0;
|
||||
let existing_cam = params.get(CAMERA).map(|v| v.as_f64() as u64).filter(|id| *id > 0 && comp.layer(LayerId(*id)).is_some_and(Layer::is_camera));
|
||||
// Targets: one, or one per selected point (Create Multiple …) sharing the selection's plane.
|
||||
let targets: Vec<Target> = if kind == CreateKind::Camera {
|
||||
vec![]
|
||||
} else {
|
||||
let t = target_p(s, p, &pl, k, cmd)?;
|
||||
if multiple {
|
||||
let ids = ids_p(s, p);
|
||||
ids.iter().filter_map(|i| pl.solve.point(*i)).map(|sp| Target { center: sp.pos, ..t }).collect()
|
||||
} else {
|
||||
vec![t]
|
||||
}
|
||||
};
|
||||
// Camera right vector on this frame (world), to align created layers with the view.
|
||||
let cam = &pl.solve.frames[k];
|
||||
let right = linalg::mv(&pl.world.r, linalg::mtv(&cam.r(), [1.0, 0.0, 0.0]));
|
||||
let label = match (kind, multiple) {
|
||||
(CreateKind::Text, false) => "Create Text and Camera",
|
||||
(CreateKind::Solid, false) => "Create Solid and Camera",
|
||||
(CreateKind::Null, false) => "Create Null and Camera",
|
||||
(CreateKind::ShadowCatcher, _) => "Create Shadow Catcher, Camera and Light",
|
||||
(CreateKind::Text, true) => "Create Multiple Text Layers and Camera",
|
||||
(CreateKind::Solid, true) => "Create Multiple Solids and Camera",
|
||||
(CreateKind::Null, true) => "Create Multiple Nulls and Camera",
|
||||
(CreateKind::Camera, _) => "Create Camera",
|
||||
};
|
||||
let (camera, created) = s.edit(label, None, |proj, st| {
|
||||
let mut created = vec![];
|
||||
// The camera: reuse the tracker's, else create "3D Tracker Camera" at the top.
|
||||
let camera = match existing_cam {
|
||||
Some(id) if kind != CreateKind::Camera => LayerId(id),
|
||||
Some(id) => {
|
||||
let l = proj.comp_mut(cid).and_then(|c| c.layer_mut(LayerId(id))).ok_or(EngineError::NoComp)?;
|
||||
write_camera(l, &pl);
|
||||
LayerId(id)
|
||||
}
|
||||
None => {
|
||||
let mut l = build::layer(proj, &comp, "3D Tracker Camera", LayerSource::Camera, (comp.width, comp.height), None);
|
||||
l.in_point = src.in_point;
|
||||
l.out_point = src.out_point;
|
||||
write_camera(&mut l, &pl);
|
||||
let id = l.id;
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.insert(0, l);
|
||||
if let Some(pr) = proj.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)).and_then(|g| g.prop_mut(CAMERA)) {
|
||||
pr.value = KV::Scalar(id.0 as f64);
|
||||
}
|
||||
id
|
||||
}
|
||||
};
|
||||
for t in &targets {
|
||||
let center = pl.to_world(t.center);
|
||||
let normal = linalg::normalize(linalg::mv(&pl.world.r, t.normal));
|
||||
let ori = plane_orientation(normal, right);
|
||||
let size = (t.size * pl.world.s * size_pct).max(1.0);
|
||||
let cur = proj.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
match kind {
|
||||
CreateKind::Text => {
|
||||
let mut l = build::layer(proj, &cur, "Text", LayerSource::Text, (comp.width, comp.height), None);
|
||||
let doc = TextDoc { text: "Text".into(), justify: Justify::Center, size: (size * 0.35).clamp(4.0, 2000.0), ..Default::default() };
|
||||
if let Some(pr) = l.props.prop_mut("text/sourceText") {
|
||||
pr.value = KV::Text(Box::new(doc));
|
||||
}
|
||||
place(&mut l, center, ori, &src);
|
||||
created.push(insert_above(proj, cid, lid, l)?);
|
||||
}
|
||||
CreateKind::Solid => {
|
||||
let name = next_name(&cur, "Track Solid");
|
||||
let c = point_color((created.len() as u32).wrapping_add(7));
|
||||
let mut l = add_solid(proj, &cur, &name, size.round().max(1.0) as u32, c);
|
||||
place(&mut l, center, ori, &src);
|
||||
created.push(insert_above(proj, cid, lid, l)?);
|
||||
}
|
||||
CreateKind::Null => {
|
||||
let name = next_name(&cur, "Track Null");
|
||||
let mut l = build::layer(proj, &cur, &name, LayerSource::Null, (comp.width, comp.height), None);
|
||||
place(&mut l, center, ori, &src);
|
||||
if let Some(pr) = l.props.prop_mut("transform/scale") {
|
||||
pr.value = KV::Vec3([size, size, size]);
|
||||
}
|
||||
created.push(insert_above(proj, cid, lid, l)?);
|
||||
}
|
||||
CreateKind::ShadowCatcher => {
|
||||
let name = next_name(&cur, "Shadow Catcher");
|
||||
let mut l = add_solid(proj, &cur, &name, (size * 4.0).round().max(1.0) as u32, [1.0, 1.0, 1.0]);
|
||||
place(&mut l, center, ori, &src);
|
||||
if let Some(pr) = l.props.prop_mut("materialOptions/acceptsShadows") {
|
||||
pr.value = KV::Enum(2);
|
||||
}
|
||||
if let Some(pr) = l.props.prop_mut("materialOptions/castsShadows") {
|
||||
pr.value = KV::Enum(0);
|
||||
}
|
||||
let sc = created.len();
|
||||
created.push(insert_above(proj, cid, lid, l)?);
|
||||
let cur = proj.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let lname = next_name(&cur, "Light");
|
||||
let mut light = build::layer(proj, &cur, &lname, LayerSource::Light { kind: LightKind::Point }, (comp.width, comp.height), None);
|
||||
light.in_point = src.in_point;
|
||||
light.out_point = src.out_point;
|
||||
let lp = linalg::add(center, linalg::add(linalg::scale(normal, size * 2.0), linalg::scale(right, size)));
|
||||
if let Some(pr) = light.props.prop_mut("transform/position") {
|
||||
pr.value = KV::Vec3(lp);
|
||||
}
|
||||
if let Some(pr) = light.props.prop_mut("lightOptions/castsShadows") {
|
||||
pr.value = KV::Bool(true);
|
||||
}
|
||||
let _ = sc;
|
||||
created.push(insert_above(proj, cid, created[created.len() - 1], light)?);
|
||||
}
|
||||
CreateKind::Camera => {}
|
||||
}
|
||||
}
|
||||
st.selected_layers = if created.is_empty() { vec![camera] } else { created.clone() };
|
||||
st.selected_props.clear();
|
||||
Ok((camera, created))
|
||||
})?;
|
||||
let t0 = targets.first().map(
|
||||
|t| json!({"center": pl.to_world(t.center), "normal": linalg::normalize(linalg::mv(&pl.world.r, t.normal)), "size": t.size * pl.world.s * size_pct}),
|
||||
);
|
||||
Ok(json!({"camera": camera.0, "layers": created.iter().map(|l| l.0).collect::<Vec<_>>(), "target": t0}))
|
||||
}
|
||||
|
||||
fn set_ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "camera.setGroundPlane";
|
||||
let (cid, lid, uid) = ct_p(s, p, cmd)?;
|
||||
let pl = placed(&s.project, cid, lid, uid).ok_or_else(|| bad(cmd, "the camera is not solved yet"))?;
|
||||
let k = pl.frame_at(s.time_of(cid));
|
||||
let ids = ids_p(s, p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select track points (or give `points`)"));
|
||||
}
|
||||
let t = target_for(&pl.solve, &ids, k).ok_or_else(|| bad(cmd, "those points do not define a plane"))?;
|
||||
let mut sv = (*pl.solve).clone();
|
||||
let mut g = sv.ground_from(&ids, k).ok_or_else(|| bad(cmd, "those points do not define a plane"))?;
|
||||
g.origin = t.center;
|
||||
sv.ground = Some(g);
|
||||
let json = sv.to_json();
|
||||
s.edit("Set Ground Plane and Origin", None, |proj, _| {
|
||||
let pr = proj
|
||||
.comp_mut(cid)
|
||||
.and_then(|c| c.layer_mut(lid))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.and_then(|g| g.prop_mut(SOLVE))
|
||||
.ok_or_else(|| EngineError::Other("no 3D Camera Tracker".into()))?;
|
||||
pr.value = KV::Str(json);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"origin": g.origin, "normal": g.normal}))
|
||||
}
|
||||
|
||||
/// Delete points (and, with Auto-delete Points Across Time, the points of other tracks on the
|
||||
/// same 3D feature), then re-solve.
|
||||
fn delete_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "camera.deletePoints";
|
||||
let (cid, lid, uid) = ct_p(s, p, cmd)?;
|
||||
let ids = ids_p(s, p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select track points (or give `points`)"));
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let g = comp.layer(lid).and_then(|l| l.props.find_group(uid)).ok_or(EngineError::NoComp)?;
|
||||
let params = static_params(g);
|
||||
let mut del = ct::deleted(¶ms);
|
||||
let auto = effectcraft_effects::warp_stab::param(¶ms, "advanced/autoDeletePoints").is_some_and(KV::as_bool);
|
||||
let mut add = ids.clone();
|
||||
if auto && let Some(sv) = ct::solve(¶ms) {
|
||||
// Same feature tracked again at other times: solved within 1 % of its depth.
|
||||
let cam0 = sv.first_solved().map(|k| sv.frames[k].center).unwrap_or([0.0; 3]);
|
||||
for id in &ids {
|
||||
let Some(pt) = sv.point(*id) else { continue };
|
||||
let r = linalg::norm(linalg::sub(pt.pos, cam0)) * 0.01;
|
||||
for q in &sv.points {
|
||||
let disjoint = q.last < pt.first || q.first > pt.last;
|
||||
if q.id != *id && disjoint && linalg::norm(linalg::sub(q.pos, pt.pos)) < r {
|
||||
add.push(q.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in add {
|
||||
if !del.contains(&id) {
|
||||
del.push(id);
|
||||
}
|
||||
}
|
||||
del.sort_unstable();
|
||||
let json = serde_json::to_string(&del).unwrap_or_default();
|
||||
s.edit("Delete Track Points", None, |proj, _| {
|
||||
let pr = proj
|
||||
.comp_mut(cid)
|
||||
.and_then(|c| c.layer_mut(lid))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.and_then(|g| g.prop_mut(DELETED))
|
||||
.ok_or_else(|| EngineError::Other("no 3D Camera Tracker".into()))?;
|
||||
pr.value = KV::Str(json);
|
||||
Ok(())
|
||||
})?;
|
||||
s.state.camera_points.retain(|x| !del.contains(x));
|
||||
// Re-solve now (After Effects re-solves after deleting points).
|
||||
let wait = b_p(p, "wait").unwrap_or(false);
|
||||
if !s.is_camera_analyzing() {
|
||||
s.start_camera(cid, lid, uid, wait).map_err(EngineError::Other)?;
|
||||
}
|
||||
Ok(json!({"deleted": del, "running": s.is_camera_analyzing()}))
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Layer ▸ Create: Convert to Editable Text (Photoshop type layers), Create Shapes from Text,
|
||||
//! Create Masks from Text and Create Shapes from Vector Layer (SVG footage).
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{ItemKind, LayerSource, Solid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, has_layers, layers_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
const OPS: &str = "editableText|shapesFromText|masksFromText|shapesFromVector";
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let op = str_p(p, "op").ok_or_else(|| bad("layer.create", format!("missing `op` ({OPS})")))?;
|
||||
let (cid, lids) = layers_p(s, p)?;
|
||||
if lids.is_empty() {
|
||||
return Err(bad("layer.create", "select a layer first"));
|
||||
}
|
||||
let time = s.time();
|
||||
let mut created = vec![];
|
||||
let mut skipped = vec![];
|
||||
for lid in lids {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let Some(src) = comp.layer(lid).cloned() else { continue };
|
||||
let footage = src.source.item().and_then(|i| match &s.project.item(i)?.kind {
|
||||
ItemKind::Footage(f) => Some(f.clone()),
|
||||
_ => None,
|
||||
});
|
||||
match op {
|
||||
"shapesFromVector" => {
|
||||
let Some(f) = footage.filter(|f| f.codec == "SVG" || f.path.to_ascii_lowercase().ends_with(".svg")) else {
|
||||
skipped.push(format!("{}: not a vector (SVG) footage layer", src.name));
|
||||
continue;
|
||||
};
|
||||
let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?;
|
||||
let doc = effectcraft_svg::parse(&bytes).map_err(|e| EngineError::Other(format!("{}: {e}", f.path)))?;
|
||||
let id = s.edit("Create Shapes from Vector Layer", None, |proj, st| {
|
||||
let l = crate::vector::shapes_from_vector(proj, &comp, &src, &doc);
|
||||
let id = l.id;
|
||||
insert_above(proj, cid, lid, l, true)?;
|
||||
st.selected_layers = vec![id];
|
||||
Ok(id)
|
||||
})?;
|
||||
created.push(id.0);
|
||||
}
|
||||
"shapesFromText" => {
|
||||
if !matches!(src.source, LayerSource::Text) {
|
||||
skipped.push(format!("{}: not a text layer", src.name));
|
||||
continue;
|
||||
}
|
||||
let snapshot = s.project.clone();
|
||||
let id = s.edit("Create Shapes from Text", None, |proj, st| {
|
||||
let l = crate::vector::shapes_from_text(proj, &comp, &snapshot, cid, &src, time)
|
||||
.ok_or_else(|| bad("layer.create", "the text layer has no glyphs"))?;
|
||||
let id = l.id;
|
||||
insert_above(proj, cid, lid, l, true)?;
|
||||
st.selected_layers = vec![id];
|
||||
Ok(id)
|
||||
})?;
|
||||
created.push(id.0);
|
||||
}
|
||||
"masksFromText" => {
|
||||
if !matches!(src.source, LayerSource::Text) {
|
||||
skipped.push(format!("{}: not a text layer", src.name));
|
||||
continue;
|
||||
}
|
||||
let outlines = crate::vector::text_outlines_in_comp(&s.project, cid, &comp, &src, time);
|
||||
if outlines.is_empty() {
|
||||
skipped.push(format!("{}: no glyphs", src.name));
|
||||
continue;
|
||||
}
|
||||
let id = s.edit("Create Masks from Text", None, |proj, st| {
|
||||
let name = comp.unique_layer_name(&format!("{} Outlines", src.name));
|
||||
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
|
||||
let sid = proj.add_item(
|
||||
&name,
|
||||
Label::Red,
|
||||
Some(folder),
|
||||
ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: comp.width, height: comp.height, pixel_aspect: 1.0 }),
|
||||
);
|
||||
let mut l = crate::vector::masks_layer(proj, &comp, &name, outlines, sid);
|
||||
l.in_point = src.in_point;
|
||||
l.out_point = src.out_point;
|
||||
let id = l.id;
|
||||
insert_above(proj, cid, lid, l, true)?;
|
||||
st.selected_layers = vec![id];
|
||||
Ok(id)
|
||||
})?;
|
||||
created.push(id.0);
|
||||
}
|
||||
"editableText" => {
|
||||
let Some((f, sl)) = footage.and_then(|f| f.layer.clone().map(|l| (f, l))) else {
|
||||
skipped.push(format!("{}: not a Photoshop layer", src.name));
|
||||
continue;
|
||||
};
|
||||
let bytes = s.services.read_file(&f.path).map_err(|e| EngineError::Other(format!("cannot read {}: {e}", f.path)))?;
|
||||
let psd = effectcraft_psd::Psd::parse(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let id = s.edit("Convert to Editable Text", None, |proj, st| {
|
||||
let l = crate::psd_import::editable_text(proj, &comp, &src, &psd, sl.index as usize)
|
||||
.ok_or_else(|| bad("layer.create", format!("{}: the Photoshop layer has no text", src.name)))?;
|
||||
let id = l.id;
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = c.layers.iter().position(|x| x.id == lid).ok_or(EngineError::NoComp)?;
|
||||
c.layers[at] = l;
|
||||
st.selected_layers = vec![id];
|
||||
Ok(id)
|
||||
})?;
|
||||
created.push(id.0);
|
||||
}
|
||||
other => return Err(bad("layer.create", format!("`{other}` is not available yet (supported: {OPS})"))),
|
||||
}
|
||||
}
|
||||
if created.is_empty() && !skipped.is_empty() {
|
||||
return Err(bad("layer.create", skipped.join("; ")));
|
||||
}
|
||||
Ok(json!({"layers": created, "skipped": skipped}))
|
||||
}
|
||||
|
||||
/// Insert `l` directly above layer `below`; optionally switch the source's video off (as After
|
||||
/// Effects does after converting).
|
||||
fn insert_above(
|
||||
proj: &mut effectcraft_project::Project,
|
||||
cid: effectcraft_project::ItemId,
|
||||
below: effectcraft_project::LayerId,
|
||||
l: effectcraft_project::Layer,
|
||||
hide_source: bool,
|
||||
) -> Result<()> {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = c.layers.iter().position(|x| x.id == below).unwrap_or(0);
|
||||
if hide_source && let Some(src) = c.layers.get_mut(at) {
|
||||
src.switches.video = false;
|
||||
}
|
||||
c.layers.insert(at, l);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!("layer.create", "Create", [], None, "{op: editableText|shapesFromText|masksFromText|shapesFromVector, layers?}", has_layers, create)]
|
||||
}
|
||||
@@ -525,12 +525,41 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.snapshot = None;
|
||||
s.state.viewer.show_snapshot = false;
|
||||
}
|
||||
let memory = matches!(what.as_str(), "all" | "memoryAndDisk" | "memory" | "image");
|
||||
let disk = matches!(what.as_str(), "all" | "memoryAndDisk" | "disk");
|
||||
if memory {
|
||||
s.layer_cache.clear();
|
||||
}
|
||||
let mut disk_entries = 0;
|
||||
if disk && let Some(dc) = &s.disk_cache {
|
||||
disk_entries = dc.stats().entries;
|
||||
dc.clear();
|
||||
}
|
||||
if matches!(what.as_str(), "all" | "image" | "memory" | "memoryAndDisk") {
|
||||
effectcraft_effects::roto::purge();
|
||||
}
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
s.toast(format!("Purged {what} cache"));
|
||||
Ok(json!({"purged": what}))
|
||||
Ok(json!({"purged": what, "diskEntries": disk_entries}))
|
||||
}
|
||||
|
||||
/// `cache.diskStats`: the disk cache's folder, limit and contents.
|
||||
pub(crate) fn disk_stats(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(dc) = &s.disk_cache else { return Ok(json!({"enabled": false})) };
|
||||
let st = dc.stats();
|
||||
Ok(json!({
|
||||
"enabled": true,
|
||||
"folder": dc.folder().to_string_lossy(),
|
||||
"entries": st.entries,
|
||||
"frames": dc.keys(effectcraft_render::disk_cache::Kind::Frame).len(),
|
||||
"layers": dc.keys(effectcraft_render::disk_cache::Kind::Layer).len(),
|
||||
"bytes": st.bytes,
|
||||
"maxBytes": st.max_bytes,
|
||||
"hits": st.hits,
|
||||
"misses": st.misses,
|
||||
"writes": st.writes,
|
||||
"evictions": st.evictions,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Path of the footage behind the selected layer or project item.
|
||||
@@ -599,6 +628,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("edit.selectLabelGroup", "Select Label Group", ["Edit", "Label"], None, "{}", has_props_or_layers_or_items, select_label_group),
|
||||
cmd!("edit.purgeUndo", "Undo", ["Edit", "Purge"], None, "{}", always_ok, purge),
|
||||
cmd!("edit.purge", "Purge", [], None, "{what?: all|memoryAndDisk|memory|disk|3d|image|snapshot}", always_ok, purge_caches),
|
||||
crate::query!("cache.diskStats", "Disk Cache Statistics", "{}", disk_stats),
|
||||
cmd!("edit.editOriginal", "Edit Original...", ["Edit"], Some("Cmd+E"), "{}", has_selected_footage, edit_original),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
//! Essential Graphics (Window ▸ Essential Graphics) and master properties.
|
||||
//!
|
||||
//! - **Authoring**: a comp's controls ([`EssentialGraphics`]): Add Property to Essential
|
||||
//! Graphics, Media Replacement for footage layers, groups, comments, renaming, reordering, the
|
||||
//! template name and the panel's Primary composition.
|
||||
//! - **Instances**: every precomp layer of such a comp shows the controls under **Essential
|
||||
//! Properties**; changing one overrides it for that instance only (`essential.set`, or any
|
||||
//! property edit of the master property); Push to Comp writes the override into the source
|
||||
//! comp, Revert drops it.
|
||||
//! - **Templates**: `essential.exportTemplate` writes an open `.ectemplate` (a ZIP holding
|
||||
//! `manifest.json` with the exposed controls, `project.ecproj` with the comp and everything it
|
||||
//! uses, the media files and a poster frame); `essential.importTemplate` merges one into the
|
||||
//! project and adds an instance to the active comp.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::essential::{self, ControlType, EgControl, EgKind, EssentialGraphics};
|
||||
use effectcraft_project::{GroupKind, ItemId, ItemKind, LayerId, LayerSource, ParamUi, Project, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, comp_id, has_comp, layer_p, selected_leaf_props, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd, query};
|
||||
|
||||
/// Format id written into template manifests.
|
||||
pub const TEMPLATE_FORMAT: &str = "effectcraft-template";
|
||||
pub const TEMPLATE_VERSION: u32 = 1;
|
||||
|
||||
fn control_p(p: &Value, cmd: &str) -> Result<u64> {
|
||||
p.get("control").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `control` (control id)"))
|
||||
}
|
||||
|
||||
/// Comp whose Essential Graphics a command edits: `comp`, else the panel's Primary comp, else
|
||||
/// the active comp.
|
||||
fn eg_comp(s: &Session, p: &Value) -> Result<ItemId> {
|
||||
if p.get("comp").is_some() {
|
||||
return comp_id(s, p);
|
||||
}
|
||||
if let Some(c) = s.state.essential_primary.filter(|c| s.project.comp(*c).is_some()) {
|
||||
return Ok(c);
|
||||
}
|
||||
comp_id(s, p)
|
||||
}
|
||||
|
||||
fn eg_of(proj: &mut Project, cid: ItemId) -> Result<&mut EssentialGraphics> {
|
||||
let name = proj.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
Ok(c.essential.get_or_insert_with(|| EssentialGraphics { name, controls: vec![] }))
|
||||
}
|
||||
|
||||
/// Insert `ctl` into `group` (or the top level) at `index` (or the end).
|
||||
fn insert(eg: &mut EssentialGraphics, ctl: EgControl, group: Option<u64>, index: Option<usize>, cmd: &str) -> Result<()> {
|
||||
let list = eg.list_mut(group).ok_or_else(|| bad(cmd, "`group` is not a group control"))?;
|
||||
let i = index.unwrap_or(list.len()).min(list.len());
|
||||
list.insert(i, ctl);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_primary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.state.essential_primary = Some(cid);
|
||||
s.bump();
|
||||
Ok(json!({"comp": cid.0}))
|
||||
}
|
||||
|
||||
fn set_name(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = eg_comp(s, p)?;
|
||||
let name = str_p(p, "name").ok_or_else(|| bad("essential.setName", "missing `name`"))?.to_string();
|
||||
s.edit("Essential Graphics Name", None, |proj, st| {
|
||||
st.essential_primary.get_or_insert(cid);
|
||||
eg_of(proj, cid)?.name = name.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"comp": cid.0, "name": name}))
|
||||
}
|
||||
|
||||
/// Add Property to Essential Graphics: `{layer, path|prop}` or the selected properties.
|
||||
fn add_property(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.addProperty";
|
||||
let targets: Vec<(ItemId, LayerId, Uid)> = if p.get("path").is_some() || p.get("prop").is_some() {
|
||||
vec![super::prop::prop_ref(s, p, C)?]
|
||||
} else {
|
||||
let cid = comp_id(s, p)?;
|
||||
selected_leaf_props(s).into_iter().map(|(l, u)| (cid, l, u)).collect()
|
||||
};
|
||||
if targets.is_empty() {
|
||||
return Err(bad(C, "select a property in the timeline (or give `layer` and `path`)"));
|
||||
}
|
||||
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);
|
||||
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)?;
|
||||
let pr = l.props.find(*uid).ok_or_else(|| bad(C, "no such property"))?;
|
||||
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()) {
|
||||
continue;
|
||||
}
|
||||
added.push((*cid, *lid, *uid, rename.clone().unwrap_or_else(|| pr.name.clone())));
|
||||
}
|
||||
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() {
|
||||
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)?;
|
||||
ids.push(id);
|
||||
}
|
||||
Ok(ids)
|
||||
})?;
|
||||
Ok(json!({"controls": ids}))
|
||||
}
|
||||
|
||||
fn control_type(pr: &effectcraft_project::Property) -> Option<ControlType> {
|
||||
essential::control_type(pr)
|
||||
}
|
||||
|
||||
/// Media Replacement for a footage layer.
|
||||
fn add_media(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.addMedia";
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
if !matches!(l.source, LayerSource::Footage { .. }) {
|
||||
return Err(bad(C, "Media Replacement needs a footage layer"));
|
||||
}
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("{} (Media)", l.name));
|
||||
let group = p.get("group").and_then(Value::as_u64);
|
||||
let id = s.edit("Add Media Replacement", None, |proj, st| {
|
||||
st.essential_primary.get_or_insert(cid);
|
||||
let id = proj.alloc();
|
||||
let eg = eg_of(proj, cid)?;
|
||||
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)?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
}
|
||||
|
||||
fn add_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = eg_comp(s, p)?;
|
||||
let name = str_p(p, "name").unwrap_or("Group").to_string();
|
||||
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
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")?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
}
|
||||
|
||||
fn add_comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = eg_comp(s, p)?;
|
||||
let text = str_p(p, "text").unwrap_or("Comment").to_string();
|
||||
let group = p.get("group").and_then(Value::as_u64);
|
||||
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")?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.rename";
|
||||
let cid = eg_comp(s, p)?;
|
||||
let id = control_p(p, C)?;
|
||||
let name = str_p(p, "name").or(str_p(p, "text")).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
s.edit("Rename Essential Graphics Control", None, |proj, _| {
|
||||
let c = eg_of(proj, cid)?.find_mut(id).ok_or_else(|| bad(C, format!("no control {id}")))?;
|
||||
match &mut c.kind {
|
||||
// A comment's name is its text.
|
||||
EgKind::Comment { text } => *text = name.clone(),
|
||||
_ => c.name = name.clone(),
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"control": id, "name": name}))
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.remove";
|
||||
let cid = eg_comp(s, p)?;
|
||||
let id = control_p(p, C)?;
|
||||
s.edit("Remove Essential Graphics Control", None, |proj, _| {
|
||||
eg_of(proj, cid)?.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
}
|
||||
|
||||
fn move_control(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.move";
|
||||
let cid = eg_comp(s, p)?;
|
||||
let id = control_p(p, C)?;
|
||||
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);
|
||||
if group == Some(id) {
|
||||
return Err(bad(C, "a group can't contain itself"));
|
||||
}
|
||||
s.edit("Move Essential Graphics Control", None, |proj, _| {
|
||||
let eg = eg_of(proj, cid)?;
|
||||
let ctl = eg.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?;
|
||||
if matches!(ctl.kind, EgKind::Group { .. }) && group.is_some() {
|
||||
return Err(bad(C, "groups can't be nested"));
|
||||
}
|
||||
insert(eg, ctl, group, index, C)
|
||||
})?;
|
||||
Ok(json!({"control": id}))
|
||||
}
|
||||
|
||||
/// Dropdown Menu Control ▸ Edit: the menu items of a dropdown (popup) property.
|
||||
fn edit_dropdown(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.editDropdown";
|
||||
let items: Vec<String> = match p.get("items") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).filter(|x| !x.is_empty()).collect(),
|
||||
_ => return Err(bad(C, "missing `items` (array of strings)")),
|
||||
};
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "a dropdown needs at least one item"));
|
||||
}
|
||||
let q = if p.get("path").is_none() && p.get("prop").is_none() {
|
||||
// The first Dropdown Menu Control of the layer.
|
||||
let (cid, lid) = layer_p(s, p, C)?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
let fx = l.effects().and_then(|fx| fx.groups().find(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.control.dropdown")));
|
||||
let uid = fx.and_then(|g| g.get("menu")).map(|pr| pr.uid).ok_or_else(|| bad(C, "the layer has no Dropdown Menu Control"))?;
|
||||
let mut q = p.clone();
|
||||
q["prop"] = json!(uid);
|
||||
q["layer"] = json!(lid.0);
|
||||
q
|
||||
} else {
|
||||
p.clone()
|
||||
};
|
||||
let (cid, lid, uid) = super::prop::prop_ref(s, &q, C)?;
|
||||
let n = items.len() as u32;
|
||||
s.edit("Edit Dropdown", None, |proj, _| {
|
||||
let pr = super::layer_mut(proj, cid, lid)?.props.find_mut(uid).ok_or_else(|| bad(C, "no such property"))?;
|
||||
if !matches!(pr.ui, ParamUi::Popup { .. }) {
|
||||
return Err(bad(C, "not a dropdown property"));
|
||||
}
|
||||
pr.ui = ParamUi::Popup { options: items.clone() };
|
||||
let clamp = |v: &mut KV| {
|
||||
if let KV::Enum(i) = v {
|
||||
*i = (*i).min(n - 1);
|
||||
}
|
||||
};
|
||||
clamp(&mut pr.value);
|
||||
for k in &mut pr.keys {
|
||||
clamp(&mut k.value);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"prop": uid, "items": items}))
|
||||
}
|
||||
|
||||
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;
|
||||
s.bump();
|
||||
Ok(json!({"on": on}))
|
||||
}
|
||||
|
||||
/// Properties of a comp's layers that Essential Graphics can expose (Solo Supported
|
||||
/// Properties): `[{layer, uid, path, name, type}]`.
|
||||
pub fn supported_properties(project: &Project, cid: ItemId) -> Vec<Value> {
|
||||
let Some(c) = project.comp(cid) else { return vec![] };
|
||||
let mut out = vec![];
|
||||
for l in &c.layers {
|
||||
l.props.walk("", &mut |_, pr| {
|
||||
if pr.three_d_only && !l.is_3d() {
|
||||
return;
|
||||
}
|
||||
if let Some(t) = essential::control_type(pr) {
|
||||
let path = super::match_path_of(&l.props, pr.uid).unwrap_or_default();
|
||||
if path.starts_with(essential::GROUP) {
|
||||
return;
|
||||
}
|
||||
out.push(json!({"layer": l.id.0, "layerName": l.name, "uid": pr.uid, "path": path, "name": l.props.name_path_of(pr.uid), "type": t}));
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value {
|
||||
match &c.kind {
|
||||
EgKind::Property { layer, prop } => {
|
||||
let src = essential::source_prop(project, cid, c);
|
||||
json!({
|
||||
"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()),
|
||||
"missing": src.is_none(),
|
||||
})
|
||||
}
|
||||
EgKind::Media { layer } => json!({
|
||||
"id": c.id, "name": c.name, "kind": "media", "type": "media", "layer": layer.0,
|
||||
"item": essential::source_media(project, cid, c).map(|i| i.0),
|
||||
}),
|
||||
EgKind::Comment { text } => json!({"id": c.id, "name": c.name, "kind": "comment", "text": text}),
|
||||
EgKind::Group { children } => json!({
|
||||
"id": c.id, "name": c.name, "kind": "group",
|
||||
"children": children.iter().map(|x| control_json(project, cid, x)).collect::<Vec<_>>(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = eg_comp(s, p)?;
|
||||
let eg = s.project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default();
|
||||
let name = if eg.name.is_empty() { s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default() } else { eg.name.clone() };
|
||||
Ok(json!({
|
||||
"comp": cid.0,
|
||||
"primary": s.state.essential_primary.map(|c| c.0),
|
||||
"name": name,
|
||||
"solo": s.state.essential_solo,
|
||||
"controls": eg.controls.iter().map(|c| control_json(&s.project, cid, c)).collect::<Vec<_>>(),
|
||||
"supported": if b_p(p, "supported").unwrap_or(false) { json!(supported_properties(&s.project, cid)) } else { Value::Null },
|
||||
}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ instances
|
||||
|
||||
/// The precomp layer (an instance) and its source comp.
|
||||
fn instance(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, ItemId)> {
|
||||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
match l.source {
|
||||
LayerSource::Comp { item } if essential::group(l).is_some() => Ok((cid, lid, item)),
|
||||
_ => Err(bad(cmd, "the layer is not a precomp with Essential Properties")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The master property (instance child) of a control.
|
||||
fn master_uid(s: &Session, cid: ItemId, lid: LayerId, control: u64) -> Option<Uid> {
|
||||
let l = s.project.comp(cid)?.layer(lid)?;
|
||||
let g = essential::group(l)?;
|
||||
let m = essential::match_id(control);
|
||||
let mut found = None;
|
||||
g.walk("", &mut |_, pr| {
|
||||
if pr.match_id == m {
|
||||
found = Some(pr.uid);
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
fn instance_json(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, src) = instance(s, p, "essential.instance")?;
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
|
||||
let over = essential::overridden(l);
|
||||
let eg = s.project.comp(src).and_then(|c| c.essential.clone()).unwrap_or_default();
|
||||
let t = s.time();
|
||||
let g = essential::group(l).cloned();
|
||||
let mut out = vec![];
|
||||
for c in eg.flat() {
|
||||
let m = essential::match_id(c.id);
|
||||
let mut pr = None;
|
||||
if let Some(g) = &g {
|
||||
g.walk("", &mut |_, x| {
|
||||
if x.match_id == m {
|
||||
pr = Some(x.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
let kind = match c.kind {
|
||||
EgKind::Property { .. } => "property",
|
||||
EgKind::Media { .. } => "media",
|
||||
EgKind::Comment { .. } => "comment",
|
||||
EgKind::Group { .. } => "group",
|
||||
};
|
||||
out.push(json!({
|
||||
"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)),
|
||||
"overridden": pr.as_ref().is_some_and(|x| over.contains(&x.uid)),
|
||||
}));
|
||||
}
|
||||
Ok(json!({"layer": lid.0, "comp": src.0, "name": eg.name, "controls": out}))
|
||||
}
|
||||
|
||||
/// 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 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") {
|
||||
let item = match item {
|
||||
Value::Number(n) => ItemId(n.as_u64().unwrap_or(0)),
|
||||
Value::String(name) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(C, format!("no item `{name}`")))?,
|
||||
_ => return Err(bad(C, "`item` is an item id or name")),
|
||||
};
|
||||
if !matches!(s.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if f.has_video) {
|
||||
return Err(bad(C, "replacement media must be footage with video"));
|
||||
}
|
||||
s.edit("Replace Media", None, |proj, _| {
|
||||
let pr = super::layer_mut(proj, cid, lid)?.props.find_mut(uid).ok_or_else(|| bad(C, "no such property"))?;
|
||||
pr.value = KV::Scalar(item.0 as f64);
|
||||
Ok(())
|
||||
})?;
|
||||
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`)"))?});
|
||||
if let Some(m) = p.get("merge") {
|
||||
q["merge"] = m.clone();
|
||||
}
|
||||
s.execute("prop.set", q)
|
||||
}
|
||||
|
||||
/// Controls of an instance to act on: `control` or every overridden one.
|
||||
fn targets(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Vec<(u64, Uid)> {
|
||||
let Some(l) = s.project.comp(cid).and_then(|c| c.layer(lid)) else { return vec![] };
|
||||
let over = essential::overridden(l);
|
||||
let Some(g) = essential::group(l) else { return vec![] };
|
||||
let want = p.get("control").and_then(Value::as_u64);
|
||||
let mut out = vec![];
|
||||
g.walk("", &mut |_, pr| {
|
||||
if let Some(c) = essential::control_of(&pr.match_id)
|
||||
&& want.is_none_or(|w| w == c)
|
||||
&& (want.is_some() || over.contains(&pr.uid))
|
||||
{
|
||||
out.push((c, pr.uid));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.revert";
|
||||
let (cid, lid, _) = instance(s, p, C)?;
|
||||
let ts = targets(s, cid, lid, p);
|
||||
if ts.is_empty() {
|
||||
return Ok(json!({"reverted": []}));
|
||||
}
|
||||
s.edit("Revert Essential Property", None, |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let g = l.props.sub_mut(essential::GROUP).ok_or_else(|| bad(C, "no Essential Properties"))?;
|
||||
if let GroupKind::Essential { overridden } = &mut g.kind {
|
||||
overridden.retain(|u| !ts.iter().any(|(_, x)| x == u));
|
||||
}
|
||||
for (_, uid) in &ts {
|
||||
if let Some(pr) = g.find_mut(*uid) {
|
||||
// The sync below brings back the source value.
|
||||
pr.keys.clear();
|
||||
pr.expr = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// Edits that only clear an override must not be taken for a new override: values are
|
||||
// refreshed by the sync inside `edit`, which ran after the override list was trimmed.
|
||||
Ok(json!({"reverted": ts.iter().map(|(c, _)| c).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Push to Comp: write the instance's override values into the source comp's properties (and
|
||||
/// replaced media into the source layer), then drop the overrides.
|
||||
fn push_to_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.pushToComp";
|
||||
let (cid, lid, src) = instance(s, p, C)?;
|
||||
let ts = targets(s, cid, lid, p);
|
||||
let eg = s.project.comp(src).and_then(|c| c.essential.clone()).unwrap_or_default();
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?;
|
||||
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()));
|
||||
}
|
||||
s.edit("Push Override Values to Source", None, |proj, _| {
|
||||
let sc = proj.comp_mut(src).ok_or(EngineError::NoComp)?;
|
||||
for (kind, pr) in &writes {
|
||||
match kind {
|
||||
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);
|
||||
// the source property's keys are in the inner layer's time.
|
||||
let keys: Vec<_> = pr
|
||||
.keys
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let mut k = k.clone();
|
||||
k.time = inner.layer_time(k.time);
|
||||
k
|
||||
})
|
||||
.collect();
|
||||
if let Some(target) = inner.props.find_mut(*prop) {
|
||||
target.value = pr.value.clone();
|
||||
target.keys = keys;
|
||||
}
|
||||
}
|
||||
EgKind::Media { layer } => {
|
||||
if let Some(inner) = sc.layer_mut(*layer) {
|
||||
inner.source = LayerSource::Footage { item: ItemId(pr.value.as_f64().max(0.0) as u64) };
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let g = super::layer_mut(proj, cid, lid)?.props.sub_mut(essential::GROUP).ok_or_else(|| bad(C, "no Essential Properties"))?;
|
||||
if let GroupKind::Essential { overridden } = &mut g.kind {
|
||||
overridden.retain(|u| !ts.iter().any(|(_, x)| x == u));
|
||||
}
|
||||
for (_, uid) in &ts {
|
||||
if let Some(pr) = g.find_mut(*uid) {
|
||||
pr.keys.clear();
|
||||
pr.expr = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"pushed": ts.iter().map(|(c, _)| c).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ templates
|
||||
|
||||
fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// The manifest's control list (flattened, with the group each belongs to).
|
||||
fn manifest_controls(project: &Project, cid: ItemId, eg: &EssentialGraphics) -> Vec<Value> {
|
||||
fn go(project: &Project, cid: ItemId, v: &[EgControl], group: Option<u64>, out: &mut Vec<Value>) {
|
||||
for c in v {
|
||||
let mut j = control_json(project, cid, c);
|
||||
if let Some(o) = j.as_object_mut() {
|
||||
o.remove("children");
|
||||
o.insert("group".into(), json!(group));
|
||||
}
|
||||
out.push(j);
|
||||
if let EgKind::Group { children } = &c.kind {
|
||||
go(project, cid, children, Some(c.id), out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
go(project, cid, &eg.controls, None, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn export_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.exportTemplate";
|
||||
let cid = eg_comp(s, p)?;
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string();
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let eg = comp.essential.clone().filter(|e| !e.controls.is_empty()).ok_or_else(|| bad(C, "the composition has no Essential Graphics controls"))?;
|
||||
let comp_name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| if eg.name.is_empty() { comp_name.clone() } else { eg.name.clone() });
|
||||
// The project subset: the comp and everything it uses, at the root.
|
||||
let deps = essential::dependencies(&s.project, cid);
|
||||
let mut sub = Project { settings: s.project.settings.clone(), next_id: s.project.next_id, ..Project::default() };
|
||||
let mut entries: Vec<(String, Vec<u8>)> = vec![];
|
||||
let mut media = vec![];
|
||||
for id in &deps {
|
||||
let Some(mut it) = s.project.item(*id).cloned() else { continue };
|
||||
it.parent = None;
|
||||
it.proxy = None;
|
||||
if let ItemKind::Footage(f) = &mut it.kind
|
||||
&& f.data.is_none()
|
||||
{
|
||||
let files: Vec<String> = if f.sequence.is_empty() { vec![f.path.clone()] } else { f.sequence.clone() };
|
||||
let mut stored = vec![];
|
||||
for (k, file) in files.iter().enumerate() {
|
||||
if let Ok(bytes) = s.services.read_file(file) {
|
||||
let entry = format!("media/{}/{}", id.0, file_name(file));
|
||||
entries.push((entry.clone(), bytes));
|
||||
if k == 0 {
|
||||
f.path = entry.clone();
|
||||
}
|
||||
stored.push(entry);
|
||||
}
|
||||
}
|
||||
if !stored.is_empty() {
|
||||
if !f.sequence.is_empty() {
|
||||
f.sequence = stored.clone();
|
||||
}
|
||||
media.push(json!({"item": id.0, "files": stored}));
|
||||
}
|
||||
}
|
||||
sub.items.insert(*id, it);
|
||||
}
|
||||
let manifest = json!({
|
||||
"format": TEMPLATE_FORMAT,
|
||||
"version": TEMPLATE_VERSION,
|
||||
"generator": format!("EffectCraft {}", env!("CARGO_PKG_VERSION")),
|
||||
"name": name,
|
||||
"comp": cid.0,
|
||||
"compName": comp_name,
|
||||
"width": comp.width,
|
||||
"height": comp.height,
|
||||
"frameRate": comp.frame_rate.as_f64(),
|
||||
"duration": comp.duration.seconds(),
|
||||
"controls": manifest_controls(&s.project, cid, &eg),
|
||||
"media": media,
|
||||
"poster": "poster.png",
|
||||
});
|
||||
// A poster frame (longest side ≤ 320 px).
|
||||
if let Ok((w, h, rgba)) = s.render_rgba8(cid, comp.poster_time, 320)
|
||||
&& let Ok(png) = super::comp_more::encode_png(&rgba, w, h)
|
||||
{
|
||||
entries.push(("poster.png".into(), png));
|
||||
}
|
||||
entries.insert(0, ("project.ecproj".into(), sub.to_json().into_bytes()));
|
||||
entries.insert(0, ("manifest.json".into(), serde_json::to_vec_pretty(&manifest).unwrap_or_default()));
|
||||
let bytes = effectcraft_lottie::zip::store(&entries);
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.toast(format!("Exported template “{name}” to {path}"));
|
||||
Ok(json!({"path": path, "name": name, "bytes": bytes.len(), "controls": eg.flat().len(), "items": deps.len()}))
|
||||
}
|
||||
|
||||
/// Read a template's manifest and project (with media paths still archive-relative).
|
||||
pub fn read_template(bytes: &[u8]) -> std::result::Result<(Value, Project, Vec<(String, Vec<u8>)>), String> {
|
||||
let entries = effectcraft_lottie::zip::read_stored(bytes);
|
||||
let get = |n: &str| entries.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone());
|
||||
let manifest: Value =
|
||||
serde_json::from_slice(&get("manifest.json").ok_or("not an EffectCraft template (no manifest.json)")?).map_err(|e| format!("manifest.json: {e}"))?;
|
||||
if manifest.get("format").and_then(Value::as_str) != Some(TEMPLATE_FORMAT) {
|
||||
return Err("not an EffectCraft template".into());
|
||||
}
|
||||
if manifest.get("version").and_then(Value::as_u64).unwrap_or(0) > TEMPLATE_VERSION as u64 {
|
||||
return Err("the template was made by a newer EffectCraft".into());
|
||||
}
|
||||
let text = String::from_utf8(get("project.ecproj").ok_or("template has no project.ecproj")?).map_err(|e| e.to_string())?;
|
||||
let project = Project::from_json(&text).map_err(|e| e.to_string())?;
|
||||
Ok((manifest, project, entries))
|
||||
}
|
||||
|
||||
fn import_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "essential.importTemplate";
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ectemplate)"))?.to_string();
|
||||
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let (manifest, mut sub, entries) = read_template(&bytes).map_err(|e| bad(C, e))?;
|
||||
let name = manifest.get("name").and_then(Value::as_str).unwrap_or("Template").to_string();
|
||||
let tcomp = manifest.get("comp").and_then(Value::as_u64).map(ItemId).ok_or_else(|| bad(C, "manifest has no `comp`"))?;
|
||||
// Media next to the template: `<dir>/<stem> Media/<item>/<file>`.
|
||||
let pp = std::path::Path::new(&path);
|
||||
let dir = pp.parent().map(|d| d.to_path_buf()).unwrap_or_default();
|
||||
let stem = pp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Template".into());
|
||||
let media_dir = dir.join(format!("{stem} Media"));
|
||||
let mut written = std::collections::HashMap::new();
|
||||
for (k, v) in entries.iter().filter(|(k, _)| k.starts_with("media/")) {
|
||||
let rel = k.trim_start_matches("media/");
|
||||
let out = media_dir.join(rel);
|
||||
if let Some(parent) = out.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let out_s = out.to_string_lossy().to_string();
|
||||
if s.services.write_file(&out_s, v).is_ok() {
|
||||
written.insert(k.clone(), out_s);
|
||||
}
|
||||
}
|
||||
for it in sub.items.values_mut() {
|
||||
if let ItemKind::Footage(f) = &mut it.kind {
|
||||
if let Some(w) = written.get(&f.path) {
|
||||
f.path = w.clone();
|
||||
}
|
||||
for x in &mut f.sequence {
|
||||
if let Some(w) = written.get(x) {
|
||||
*x = w.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let target = if b_p(p, "addToComp") == Some(false) { None } else { comp_id(s, p).ok() };
|
||||
let base = s.project.next_id;
|
||||
essential::offset_ids(&mut sub, base);
|
||||
let new_comp = ItemId(tcomp.0 + base);
|
||||
let ids = s.edit("Import Template", None, |proj, st| {
|
||||
let folder = proj.add_item(&name, effectcraft_color::Label::Yellow, None, ItemKind::Folder);
|
||||
let mut ids = vec![];
|
||||
for (id, mut it) in std::mem::take(&mut sub.items) {
|
||||
if it.parent.is_none() {
|
||||
it.parent = Some(folder);
|
||||
}
|
||||
ids.push(id.0);
|
||||
proj.items.insert(id, it);
|
||||
}
|
||||
proj.next_id = proj.next_id.max(sub.next_id);
|
||||
proj.fix_next_id();
|
||||
st.project_selection = vec![new_comp];
|
||||
Ok(ids)
|
||||
})?;
|
||||
let mut layer = Value::Null;
|
||||
if let Some(cid) = target
|
||||
&& s.project.comp(cid).is_some()
|
||||
{
|
||||
let r = s.execute("layer.addItem", json!({"comp": cid.0, "item": new_comp.0, "time": 0.0}))?;
|
||||
layer = r["layer"].clone();
|
||||
}
|
||||
s.toast(format!("Imported template “{name}”"));
|
||||
Ok(json!({"comp": new_comp.0, "items": ids, "layer": layer, "name": name}))
|
||||
}
|
||||
|
||||
/// Read a template's manifest without importing it (Essential Graphics / agents).
|
||||
fn template_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("essential.templateInfo", "missing `path`"))?;
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let (manifest, _, _) = read_template(&bytes).map_err(|e| bad("essential.templateInfo", e))?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn has_instance(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
let ok = s.active_comp().is_some_and(|c| s.state.selected_layers.iter().any(|l| c.layer(*l).is_some_and(|l| essential::group(l).is_some())));
|
||||
if ok { Ok(()) } else { Err("select a precomp layer with Essential Properties".into()) }
|
||||
}
|
||||
|
||||
fn has_eg(s: &Session) -> std::result::Result<(), String> {
|
||||
let cid = s.state.essential_primary.or(s.state.active_comp).ok_or("no composition is open")?;
|
||||
if s.project.comp(cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| !e.controls.is_empty()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("add properties to Essential Graphics first".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn has_selected_props(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
if s.state.selected_props.is_empty() { Err("select a property in the timeline".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("essential.setPrimary", "Primary Composition", [], None, "{comp}", has_comp, set_primary),
|
||||
cmd!("essential.setName", "Essential Graphics Name", [], None, "{comp?, name}", has_comp, set_name),
|
||||
cmd!(
|
||||
"essential.addProperty",
|
||||
"Add Property to Essential Graphics",
|
||||
["Animation"],
|
||||
None,
|
||||
"{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?}",
|
||||
has_selected_props,
|
||||
add_property
|
||||
),
|
||||
cmd!("essential.addMedia", "Add Media Replacement", [], None, "{layer, name?, group?}", has_comp, add_media),
|
||||
cmd!("essential.addGroup", "Add Group", [], None, "{comp?, name?, index?}", has_comp, add_group),
|
||||
cmd!("essential.addComment", "Add Comment", [], None, "{comp?, text?, group?}", has_comp, add_comment),
|
||||
cmd!("essential.rename", "Rename Control", [], None, "{comp?, control, name}", has_comp, rename),
|
||||
cmd!("essential.remove", "Remove Control", [], None, "{comp?, control}", has_comp, remove),
|
||||
cmd!("essential.move", "Move Control", [], None, "{comp?, control, group?: id (null = top level), index?}", has_comp, move_control),
|
||||
cmd!("essential.soloSupported", "Solo Supported Properties", [], None, "{on?}", has_comp, solo_supported),
|
||||
cmd!(
|
||||
"essential.exportTemplate",
|
||||
"Essential Graphics Template...",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{comp?, path (.ectemplate), name?}",
|
||||
has_eg,
|
||||
export_template
|
||||
),
|
||||
cmd!(
|
||||
"essential.importTemplate",
|
||||
"Essential Graphics Template...",
|
||||
["File", "Import"],
|
||||
None,
|
||||
"{path (.ectemplate), comp?, addToComp?: bool}",
|
||||
super::always,
|
||||
import_template
|
||||
),
|
||||
cmd!("essential.set", "Set Essential Property", [], None, "{layer, control, value | item (media)}", has_instance, set_override),
|
||||
cmd!("essential.pushToComp", "Push Override Values to Source", [], None, "{layer, control? (default: all overridden)}", has_instance, push_to_comp),
|
||||
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.instance", "Essential Properties of a precomp layer", "{layer}", instance_json),
|
||||
query!("essential.templateInfo", "Read a template's manifest", "{path}", template_info),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Expression tools: the error list behind the expression error bar, and the Expression
|
||||
//! Language menu (the ⓕ button next to an expression), written from the public Expression
|
||||
//! Language Reference.
|
||||
|
||||
use effectcraft_project::{ItemId, Layer, Property};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_time::Tick;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, comp_id, f_p};
|
||||
use crate::{EngineError, Result, Session, query};
|
||||
|
||||
/// One failing expression.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct ExprError {
|
||||
pub comp: u64,
|
||||
pub layer: u64,
|
||||
#[serde(rename = "layerIndex")]
|
||||
pub layer_index: usize,
|
||||
#[serde(rename = "layerName")]
|
||||
pub layer_name: String,
|
||||
pub prop: u64,
|
||||
/// Match path (`transform/opacity`).
|
||||
pub path: String,
|
||||
/// Property name path as shown in the timeline (`Transform/Opacity`).
|
||||
pub name: String,
|
||||
pub message: String,
|
||||
/// The expression is disabled (it failed to compile).
|
||||
pub disabled: bool,
|
||||
}
|
||||
|
||||
impl ExprError {
|
||||
/// The error bar's text: `Error at line 1 in property 'Opacity' of layer 1 ('Solid') in comp 'Main'. …`
|
||||
pub fn describe(&self, comp_name: &str) -> String {
|
||||
let prop = self.name.rsplit('/').next().unwrap_or(&self.name);
|
||||
format!("{} — property '{}' of layer {} ('{}') in comp '{}'", self.message, prop, self.layer_index, self.layer_name, comp_name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Every failing expression of comp `cid` at comp time `t`: evaluation errors of enabled
|
||||
/// expressions and the syntax errors that disabled others.
|
||||
pub fn errors(s: &Session, cid: ItemId, t: Tick) -> Vec<ExprError> {
|
||||
let Some(comp) = s.project.comp(cid) else { return vec![] };
|
||||
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, t) };
|
||||
let mut out = vec![];
|
||||
for (i, l) in comp.layers.iter().enumerate() {
|
||||
let mut props: Vec<&Property> = vec![];
|
||||
l.props.walk("", &mut |_, p| {
|
||||
if p.expr.as_ref().is_some_and(|e| !e.text.trim().is_empty()) {
|
||||
props.push(p);
|
||||
}
|
||||
});
|
||||
for p in props {
|
||||
let e = p.expr.as_ref().expect("filtered");
|
||||
let msg = if e.enabled {
|
||||
match &ctx.expr {
|
||||
Some(h) => h.eval(&ctx, l, p, &p.value_at(l.layer_time(t))).err(),
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
s.expr_check.and_then(|check| check(&e.text).err())
|
||||
};
|
||||
if let Some(message) = msg {
|
||||
out.push(error_of(cid, i, l, p, message, !e.enabled));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn error_of(cid: ItemId, i: usize, l: &Layer, p: &Property, message: String, disabled: bool) -> ExprError {
|
||||
ExprError {
|
||||
comp: cid.0,
|
||||
layer: l.id.0,
|
||||
layer_index: i + 1,
|
||||
layer_name: l.name.clone(),
|
||||
prop: p.uid,
|
||||
path: super::match_path_of(&l.props, p.uid).unwrap_or_default(),
|
||||
name: l.props.name_path_of(p.uid).unwrap_or_else(|| p.name.clone()),
|
||||
message,
|
||||
disabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn errors_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time_of(cid));
|
||||
let name = s.project.item(cid).map(|i| i.name.clone()).ok_or(EngineError::NoComp)?;
|
||||
let list = errors(s, cid, t);
|
||||
let text: Vec<String> = list.iter().map(|e| e.describe(&name)).collect();
|
||||
Ok(json!({"comp": cid.0, "time": t.seconds(), "count": list.len(), "errors": list, "text": text}))
|
||||
}
|
||||
|
||||
/// The Expression Language menu: (category, [(label, inserted text)]). Submenus are nested
|
||||
/// with `▸` in the category name.
|
||||
pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
|
||||
vec![
|
||||
(
|
||||
"Global",
|
||||
vec![
|
||||
("comp(name)", "comp(\"name\")"),
|
||||
("footage(name)", "footage(\"name\")"),
|
||||
("thisComp", "thisComp"),
|
||||
("thisLayer", "thisLayer"),
|
||||
("thisProperty", "thisProperty"),
|
||||
("thisProject", "thisProject"),
|
||||
("time", "time"),
|
||||
("colorDepth", "colorDepth"),
|
||||
("posterizeTime(framesPerSecond)", "posterizeTime(framesPerSecond)"),
|
||||
("value", "value"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Vector Math",
|
||||
vec![
|
||||
("add(vec1, vec2)", "add(vec1, vec2)"),
|
||||
("sub(vec1, vec2)", "sub(vec1, vec2)"),
|
||||
("mul(vec, amount)", "mul(vec, amount)"),
|
||||
("div(vec, amount)", "div(vec, amount)"),
|
||||
("clamp(value, limit1, limit2)", "clamp(value, limit1, limit2)"),
|
||||
("dot(vec1, vec2)", "dot(vec1, vec2)"),
|
||||
("cross(vec1, vec2)", "cross(vec1, vec2)"),
|
||||
("normalize(vec)", "normalize(vec)"),
|
||||
("length(vec)", "length(vec)"),
|
||||
("length(point1, point2)", "length(point1, point2)"),
|
||||
("lookAt(fromPoint, atPoint)", "lookAt(fromPoint, atPoint)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Random Numbers",
|
||||
vec![
|
||||
("seedRandom(offset, timeless=false)", "seedRandom(offset, timeless=false)"),
|
||||
("random()", "random()"),
|
||||
("random(maxValOrArray)", "random(maxValOrArray)"),
|
||||
("random(minValOrArray, maxValOrArray)", "random(minValOrArray, maxValOrArray)"),
|
||||
("gaussRandom()", "gaussRandom()"),
|
||||
("gaussRandom(maxValOrArray)", "gaussRandom(maxValOrArray)"),
|
||||
("gaussRandom(minValOrArray, maxValOrArray)", "gaussRandom(minValOrArray, maxValOrArray)"),
|
||||
("noise(valOrArray)", "noise(valOrArray)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Interpolation",
|
||||
vec![
|
||||
("linear(t, value1, value2)", "linear(t, value1, value2)"),
|
||||
("linear(t, tMin, tMax, value1, value2)", "linear(t, tMin, tMax, value1, value2)"),
|
||||
("ease(t, value1, value2)", "ease(t, value1, value2)"),
|
||||
("ease(t, tMin, tMax, value1, value2)", "ease(t, tMin, tMax, value1, value2)"),
|
||||
("easeIn(t, value1, value2)", "easeIn(t, value1, value2)"),
|
||||
("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"),
|
||||
("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"),
|
||||
("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Color Conversion",
|
||||
vec![
|
||||
("rgbToHsl(rgbaArray)", "rgbToHsl(rgbaArray)"),
|
||||
("hslToRgb(hslaArray)", "hslToRgb(hslaArray)"),
|
||||
("hexToRgb(hexString)", "hexToRgb(hexString)"),
|
||||
],
|
||||
),
|
||||
("Other Math", vec![("degreesToRadians(degrees)", "degreesToRadians(degrees)"), ("radiansToDegrees(radians)", "radiansToDegrees(radians)")]),
|
||||
(
|
||||
"JavaScript Math",
|
||||
vec![
|
||||
("Math.cos(value)", "Math.cos(value)"),
|
||||
("Math.sin(value)", "Math.sin(value)"),
|
||||
("Math.atan2(y, x)", "Math.atan2(y, x)"),
|
||||
("Math.sqrt(value)", "Math.sqrt(value)"),
|
||||
("Math.abs(value)", "Math.abs(value)"),
|
||||
("Math.round(value)", "Math.round(value)"),
|
||||
("Math.floor(value)", "Math.floor(value)"),
|
||||
("Math.min(value1, value2)", "Math.min(value1, value2)"),
|
||||
("Math.max(value1, value2)", "Math.max(value1, value2)"),
|
||||
("Math.PI", "Math.PI"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Comp",
|
||||
vec![
|
||||
("layer(index)", "layer(index)"),
|
||||
("layer(name)", "layer(\"name\")"),
|
||||
("layer(otherLayer, relIndex)", "layer(otherLayer, relIndex)"),
|
||||
("marker", "marker"),
|
||||
("numLayers", "numLayers"),
|
||||
("activeCamera", "activeCamera"),
|
||||
("width", "width"),
|
||||
("height", "height"),
|
||||
("duration", "duration"),
|
||||
("frameDuration", "frameDuration"),
|
||||
("pixelAspect", "pixelAspect"),
|
||||
("name", "name"),
|
||||
("bgColor", "bgColor"),
|
||||
("displayStartTime", "displayStartTime"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Footage",
|
||||
vec![
|
||||
("width", "width"),
|
||||
("height", "height"),
|
||||
("duration", "duration"),
|
||||
("frameDuration", "frameDuration"),
|
||||
("pixelAspect", "pixelAspect"),
|
||||
("name", "name"),
|
||||
("sourceText", "sourceText"),
|
||||
("sourceData", "sourceData"),
|
||||
("dataValue(dataPath)", "dataValue(dataPath)"),
|
||||
("dataKeyCount", "dataKeyCount"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer > Sub-objects",
|
||||
vec![
|
||||
("source", "source"),
|
||||
("effect(name)", "effect(\"name\")"),
|
||||
("effect(index)", "effect(index)"),
|
||||
("mask(name)", "mask(\"name\")"),
|
||||
("mask(index)", "mask(index)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer > General",
|
||||
vec![
|
||||
("width", "width"),
|
||||
("height", "height"),
|
||||
("index", "index"),
|
||||
("parent", "parent"),
|
||||
("hasParent", "hasParent"),
|
||||
("inPoint", "inPoint"),
|
||||
("outPoint", "outPoint"),
|
||||
("startTime", "startTime"),
|
||||
("hasVideo", "hasVideo"),
|
||||
("enabled", "enabled"),
|
||||
("active", "active"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer > Properties",
|
||||
vec![
|
||||
("anchorPoint", "anchorPoint"),
|
||||
("position", "position"),
|
||||
("scale", "scale"),
|
||||
("rotation", "rotation"),
|
||||
("opacity", "opacity"),
|
||||
("marker", "marker"),
|
||||
("name", "name"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer > Space Transforms",
|
||||
vec![
|
||||
("toComp(point, t = time)", "toComp(point, t = time)"),
|
||||
("fromComp(point, t = time)", "fromComp(point, t = time)"),
|
||||
("toWorld(point, t = time)", "toWorld(point, t = time)"),
|
||||
("fromWorld(point, t = time)", "fromWorld(point, t = time)"),
|
||||
("toCompVec(vec, t = time)", "toCompVec(vec, t = time)"),
|
||||
("fromCompVec(vec, t = time)", "fromCompVec(vec, t = time)"),
|
||||
("toWorldVec(vec, t = time)", "toWorldVec(vec, t = time)"),
|
||||
("fromWorldVec(vec, t = time)", "fromWorldVec(vec, t = time)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Layer > Other",
|
||||
vec![
|
||||
("sourceRectAtTime(t = time, includeExtents = false)", "sourceRectAtTime(t = time, includeExtents = false)"),
|
||||
("sampleImage(point, radius = [.5, .5], postEffect = true, t = time)", "sampleImage(point, radius = [.5, .5], postEffect = true, t = time)"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Property",
|
||||
vec![
|
||||
("value", "value"),
|
||||
("valueAtTime(t)", "valueAtTime(t)"),
|
||||
("velocity", "velocity"),
|
||||
("velocityAtTime(t)", "velocityAtTime(t)"),
|
||||
("speed", "speed"),
|
||||
("speedAtTime(t)", "speedAtTime(t)"),
|
||||
("wiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)", "wiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)"),
|
||||
("temporalWiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)", "temporalWiggle(freq, amp, octaves = 1, amp_mult = .5, t = time)"),
|
||||
("smooth(width = .2, samples = 5, t = time)", "smooth(width = .2, samples = 5, t = time)"),
|
||||
("loopIn(type = \"cycle\", numKeyframes = 0)", "loopIn(type = \"cycle\", numKeyframes = 0)"),
|
||||
("loopOut(type = \"cycle\", numKeyframes = 0)", "loopOut(type = \"cycle\", numKeyframes = 0)"),
|
||||
("loopInDuration(type = \"cycle\", duration = 0)", "loopInDuration(type = \"cycle\", duration = 0)"),
|
||||
("loopOutDuration(type = \"cycle\", duration = 0)", "loopOutDuration(type = \"cycle\", duration = 0)"),
|
||||
("key(index)", "key(index)"),
|
||||
("key(markerName)", "key(markerName)"),
|
||||
("nearestKey(t)", "nearestKey(t)"),
|
||||
("numKeys", "numKeys"),
|
||||
("name", "name"),
|
||||
("propertyIndex", "propertyIndex"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Path Property",
|
||||
vec![
|
||||
("points(t = time)", "points(t = time)"),
|
||||
("inTangents(t = time)", "inTangents(t = time)"),
|
||||
("outTangents(t = time)", "outTangents(t = time)"),
|
||||
("isClosed()", "isClosed()"),
|
||||
(
|
||||
"createPath(points, inTangents = [], outTangents = [], is_closed = true)",
|
||||
"createPath(points, inTangents = [], outTangents = [], is_closed = true)",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Text > Style",
|
||||
vec![
|
||||
("text.sourceText.style", "text.sourceText.style"),
|
||||
("getStyleAt(charIndex, t = time)", "getStyleAt(charIndex, t = time)"),
|
||||
("createStyle()", "createStyle()"),
|
||||
("setFontSize(value)", "setFontSize(value)"),
|
||||
("setFillColor(value)", "setFillColor(value)"),
|
||||
("setText(value)", "setText(value)"),
|
||||
],
|
||||
),
|
||||
("Text > Expression Selector", vec![("textIndex", "textIndex"), ("textTotal", "textTotal"), ("selectorValue", "selectorValue")]),
|
||||
("Key", vec![("value", "value"), ("time", "time"), ("index", "index")]),
|
||||
(
|
||||
"Marker Key",
|
||||
vec![
|
||||
("duration", "duration"),
|
||||
("comment", "comment"),
|
||||
("chapter", "chapter"),
|
||||
("url", "url"),
|
||||
("frameTarget", "frameTarget"),
|
||||
("eventCuePoint", "eventCuePoint"),
|
||||
("cuePointName", "cuePointName"),
|
||||
("parameters", "parameters"),
|
||||
("protectedRegion", "protectedRegion"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Time Conversion",
|
||||
vec![
|
||||
(
|
||||
"timeToFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
|
||||
"timeToFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
|
||||
),
|
||||
("framesToTime(frames, fps = 1.0 / thisComp.frameDuration)", "framesToTime(frames, fps = 1.0 / thisComp.frameDuration)"),
|
||||
(
|
||||
"timeToTimecode(t = time + thisComp.displayStartTime, timecodeBase = 30, isDuration = false)",
|
||||
"timeToTimecode(t = time + thisComp.displayStartTime, timecodeBase = 30, isDuration = false)",
|
||||
),
|
||||
(
|
||||
"timeToFeetAndFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, framesPerFoot = 16, isDuration = false)",
|
||||
"timeToFeetAndFrames(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, framesPerFoot = 16, isDuration = false)",
|
||||
),
|
||||
(
|
||||
"timeToCurrentFormat(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
|
||||
"timeToCurrentFormat(t = time + thisComp.displayStartTime, fps = 1.0 / thisComp.frameDuration, isDuration = false)",
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn menu_cmd(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!(
|
||||
language_menu()
|
||||
.into_iter()
|
||||
.map(|(c, items)| json!({"category": c, "items": items.into_iter().map(|(l, t)| json!({"label": l, "text": t})).collect::<Vec<_>>()}))
|
||||
.collect::<Vec<_>>()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!("expr.errors", "Expression errors (the error bar)", "{comp?, time? (s)}", errors_cmd),
|
||||
query!("expr.languageMenu", "Expression Language menu", "{}", menu_cmd),
|
||||
]
|
||||
}
|
||||
@@ -69,7 +69,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?;
|
||||
// After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`.
|
||||
let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists());
|
||||
let next = crate::autosave::increment_path(&cur, |p| s.file_ops().is_file(std::path::Path::new(p)));
|
||||
save_to(s, &next)
|
||||
}
|
||||
|
||||
@@ -84,12 +84,61 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Value::String(x)) => vec![x.clone()],
|
||||
_ => return Err(bad("file.import", "missing `paths`")),
|
||||
};
|
||||
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
|
||||
// Photoshop documents as compositions (Import As: Composition / – Retain Layer Sizes).
|
||||
let import_as = str_p(p, "importAs").unwrap_or("footage");
|
||||
let retain = match import_as {
|
||||
"footage" => None,
|
||||
"composition" | "comp" => Some(false),
|
||||
"compositionLayerSizes" | "compositionRetainLayerSizes" | "layerSizes" => Some(true),
|
||||
other => return Err(bad("file.import", format!("importAs: footage|composition|compositionLayerSizes, not `{other}`"))),
|
||||
};
|
||||
let mut paths = paths;
|
||||
let mut out_comps = vec![];
|
||||
let mut ids = vec![];
|
||||
let mut errors = vec![];
|
||||
if let Some(retain) = retain {
|
||||
let mut rest = vec![];
|
||||
for path in paths {
|
||||
let bytes = match s.services.read_file(&path) {
|
||||
Ok(b) if effectcraft_psd::is_psd(&b) => b,
|
||||
_ => {
|
||||
rest.push(path);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match import_psd_comp(s, &path, bytes, retain) {
|
||||
Ok((comp, items, warnings)) => {
|
||||
out_comps.push(comp);
|
||||
ids.extend(items);
|
||||
errors.extend(warnings);
|
||||
}
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
paths = rest;
|
||||
if paths.is_empty() {
|
||||
if let Some(c) = out_comps.first() {
|
||||
s.open_comp(effectcraft_project::ItemId(*c));
|
||||
}
|
||||
return Ok(json!({"items": ids, "comps": out_comps, "errors": errors}));
|
||||
}
|
||||
}
|
||||
let mut probed = vec![];
|
||||
let psd_layer = p.get("layer").cloned();
|
||||
let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps);
|
||||
for path in &paths {
|
||||
// Data files (JSON, CSV, TSV) for data-driven animation: kept as text in the project.
|
||||
if let Some(f) = data_footage(s, path) {
|
||||
match f {
|
||||
Ok(f) => probed.push((path.clone(), f)),
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(importer) = s.importer.clone() else {
|
||||
errors.push(format!("{path}: media import is not available in this build"));
|
||||
continue;
|
||||
};
|
||||
match importer.probe(path) {
|
||||
Ok(mut f) => {
|
||||
// Settings ▸ Import ▸ Sequence Footage frames per second.
|
||||
@@ -99,6 +148,18 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
f.frame_rate = r;
|
||||
f.duration = r.tick_of(frames.max(1));
|
||||
}
|
||||
// Choose Layer: one layer of a Photoshop document (document-sized).
|
||||
if let Some(sel) = &psd_layer
|
||||
&& f.codec == "PSD"
|
||||
{
|
||||
match s.services.read_file(path).ok().and_then(|b| effectcraft_psd::Psd::parse(b).ok()).and_then(|d| find_psd_layer(&d, sel)) {
|
||||
Some((index, name)) => f.layer = Some(effectcraft_project::SourceLayer { index: index as u32, name, layer_size: false }),
|
||||
None => {
|
||||
errors.push(format!("{path}: no layer {sel}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
probed.push((path.clone(), f))
|
||||
}
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
@@ -107,10 +168,15 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Import", None, |proj, st| {
|
||||
for (path, f) in probed {
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
|
||||
let name = match &f.layer {
|
||||
Some(l) => format!("{}/{name}", l.name),
|
||||
None => name,
|
||||
};
|
||||
let label = match f.kind {
|
||||
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
|
||||
FootageKind::Audio => Label::SeaFoam,
|
||||
FootageKind::Video | FootageKind::Model => Label::Aqua,
|
||||
FootageKind::Data => Label::Sandstone,
|
||||
};
|
||||
let id = proj.add_item(&name, label, None, ItemKind::Footage(f));
|
||||
ids.push(id.0);
|
||||
@@ -118,7 +184,56 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": ids, "errors": errors}))
|
||||
if let Some(c) = out_comps.first() {
|
||||
s.open_comp(effectcraft_project::ItemId(*c));
|
||||
}
|
||||
Ok(json!({"items": ids, "comps": out_comps, "errors": errors}))
|
||||
}
|
||||
|
||||
/// A Photoshop layer by index or name (pixel layers only).
|
||||
fn find_psd_layer(d: &effectcraft_psd::Psd, sel: &Value) -> Option<(usize, String)> {
|
||||
let l = match sel {
|
||||
Value::Number(n) => d.layers.get(n.as_u64()? as usize)?,
|
||||
Value::String(name) => d.layers.iter().find(|l| &l.name == name)?,
|
||||
_ => return None,
|
||||
};
|
||||
Some((l.index, l.name.clone()))
|
||||
}
|
||||
|
||||
/// Import a Photoshop document as a composition (one undo step). Returns (comp, items, warnings).
|
||||
fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec<u8>, retain: bool) -> Result<(u64, Vec<u64>, Vec<String>)> {
|
||||
let psd = effectcraft_psd::Psd::parse(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Photoshop".into());
|
||||
let rate = effectcraft_time::FrameRate::FPS_29_97;
|
||||
let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
|
||||
let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs));
|
||||
let r = s.edit("Import", None, |proj, st| {
|
||||
let r = crate::psd_import::import(proj, &psd, path, &name, retain, rate, duration);
|
||||
st.project_selection = vec![r.comp];
|
||||
Ok(r)
|
||||
})?;
|
||||
let mut items = vec![r.folder.0];
|
||||
items.extend(r.items.iter().map(|i| i.0));
|
||||
Ok((r.comp.0, items, r.warnings))
|
||||
}
|
||||
|
||||
/// Extensions imported as data footage.
|
||||
pub const DATA_EXTENSIONS: &[&str] = &["json", "csv", "tsv"];
|
||||
|
||||
/// A data footage item for `path` (JSON / CSV / TSV), `None` for other files.
|
||||
pub(crate) fn data_footage(s: &Session, path: &str) -> Option<std::result::Result<effectcraft_project::Footage, String>> {
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if !DATA_EXTENSIONS.contains(&ext.as_str()) {
|
||||
return None;
|
||||
}
|
||||
Some((|| {
|
||||
let bytes = s.services.read_file(path).map_err(|e| e.to_string())?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| "data files must be UTF-8 text".to_string())?;
|
||||
if ext == "json" {
|
||||
serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?;
|
||||
}
|
||||
Ok(effectcraft_project::Footage { path: path.to_string(), kind: FootageKind::Data, codec: ext.to_uppercase(), data: Some(text), ..Default::default() })
|
||||
})())
|
||||
}
|
||||
|
||||
fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -224,7 +339,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("file.saveAs", "Save As...", ["File", "Save As"], Some("Cmd+Shift+S"), "{path}", always, save_as),
|
||||
cmd!("file.incrementAndSave", "Increment and Save", ["File"], Some("Cmd+Alt+Shift+S"), "{}", has_path, increment_save),
|
||||
cmd!("file.revert", "Revert", ["File"], None, "{}", has_path, revert),
|
||||
cmd!("file.import", "File...", ["File", "Import"], Some("Cmd+I"), "{paths: [string]}", always, import),
|
||||
cmd!(
|
||||
"file.import",
|
||||
"File...",
|
||||
["File", "Import"],
|
||||
Some("Cmd+I"),
|
||||
"{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer)}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
cmd!(
|
||||
"file.projectSettings",
|
||||
"Project Settings...",
|
||||
|
||||
@@ -107,6 +107,7 @@ fn placeholder_footage(p: &Value) -> Footage {
|
||||
missing: true,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,56 +443,179 @@ fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------------------------------------------------------------- footage
|
||||
|
||||
/// Interpret Footage settings parsed from command parameters (every field optional).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Interpretation {
|
||||
rate: Option<FrameRate>,
|
||||
/// Back to the file's own rate (Use frame rate from file).
|
||||
native: bool,
|
||||
alpha: Option<AlphaMode>,
|
||||
/// Alpha ▸ Guess: decide Straight / Premultiplied (and the matte colour) from a frame.
|
||||
guess: bool,
|
||||
matte: Option<[f32; 3]>,
|
||||
invert_alpha: Option<bool>,
|
||||
loops: Option<u32>,
|
||||
par: Option<f64>,
|
||||
fields: Option<effectcraft_project::FieldOrder>,
|
||||
profile: Option<Option<effectcraft_project::ColorSpace>>,
|
||||
linear: Option<bool>,
|
||||
}
|
||||
|
||||
impl Interpretation {
|
||||
pub(crate) fn parse(p: &Value, cmd: &str) -> Result<Interpretation> {
|
||||
let mut it = Interpretation { rate: f_p(p, "frameRate").map(FrameRate::from_f64), ..Default::default() };
|
||||
if str_p(p, "frameRate") == Some("file") {
|
||||
it.native = true;
|
||||
}
|
||||
match str_p(p, "alpha") {
|
||||
Some("straight") => it.alpha = Some(AlphaMode::Straight),
|
||||
Some("premultiplied") => it.alpha = Some(AlphaMode::Premultiplied),
|
||||
Some("ignore") => it.alpha = Some(AlphaMode::Ignore),
|
||||
Some("guess") => it.guess = true,
|
||||
Some(a) => return Err(bad(cmd, format!("alpha: straight|premultiplied|ignore|guess, not `{a}`"))),
|
||||
None => {}
|
||||
}
|
||||
it.guess |= super::b_p(p, "guessAlpha").unwrap_or(false);
|
||||
it.matte = match p.get("matteColor").or(p.get("premulColor")) {
|
||||
None => None,
|
||||
Some(v) => Some(
|
||||
effectcraft_keyframe::Value::Color([0.0, 0.0, 0.0, 1.0])
|
||||
.coerce_json(v)
|
||||
.map(|c| {
|
||||
let c = c.as_color();
|
||||
[c[0], c[1], c[2]]
|
||||
})
|
||||
.ok_or_else(|| bad(cmd, "matteColor: #hex or [r, g, b] (0..1)"))?,
|
||||
),
|
||||
};
|
||||
it.invert_alpha = super::b_p(p, "invertAlpha");
|
||||
it.loops = p.get("loop").and_then(Value::as_u64).map(|v| v.clamp(1, 9999) as u32);
|
||||
it.par = f_p(p, "pixelAspect").filter(|x| *x > 0.0);
|
||||
it.fields = match str_p(p, "fields") {
|
||||
Some(f) => Some(effectcraft_project::FieldOrder::parse(f).ok_or_else(|| bad(cmd, "fields: off|upper|lower"))?),
|
||||
None => None,
|
||||
};
|
||||
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
|
||||
it.profile = match str_p(p, "colorProfile") {
|
||||
Some("auto" | "none" | "") => Some(None),
|
||||
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
|
||||
Some(cs) => Some(Some(cs)),
|
||||
None => return Err(bad(cmd, format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
it.linear = super::b_p(p, "linearLight");
|
||||
Ok(it)
|
||||
}
|
||||
|
||||
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
|
||||
if self.native {
|
||||
if let Some(n) = f.native_rate.take() {
|
||||
f.frame_rate = n;
|
||||
}
|
||||
} else if let Some(r) = self.rate {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
}
|
||||
f.frame_rate = r;
|
||||
}
|
||||
if let Some(a) = self.alpha {
|
||||
f.alpha = a;
|
||||
}
|
||||
if let Some((a, m)) = guessed {
|
||||
f.alpha = a;
|
||||
f.premul_color = m;
|
||||
}
|
||||
if let Some(m) = self.matte {
|
||||
f.premul_color = m;
|
||||
}
|
||||
if let Some(v) = self.invert_alpha {
|
||||
f.invert_alpha = v;
|
||||
}
|
||||
if let Some(l) = self.loops {
|
||||
f.loop_count = l;
|
||||
}
|
||||
if let Some(x) = self.par {
|
||||
f.pixel_aspect = x;
|
||||
}
|
||||
if let Some(x) = self.fields {
|
||||
f.fields = x;
|
||||
}
|
||||
if let Some(c) = self.profile {
|
||||
f.color_profile = c;
|
||||
}
|
||||
if let Some(v) = self.linear {
|
||||
f.linear_light = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Alpha ▸ Guess: read the first frame as straight colour and decide
|
||||
/// whether the file is premultiplied (every partly transparent pixel's colour within its alpha
|
||||
/// of a black or white matte) or straight. `None` when the frame has no partial alpha.
|
||||
pub(crate) fn guess_alpha(s: &Session, item: ItemId, f: &Footage) -> Option<(AlphaMode, [f32; 3])> {
|
||||
let probe = Footage { alpha: AlphaMode::Straight, invert_alpha: false, ..f.clone() };
|
||||
let img = s.footage.frame(item, &probe, Tick::ZERO)?;
|
||||
let (mut partial, mut black, mut white) = (0usize, true, true);
|
||||
let eps = 1.5 / 255.0;
|
||||
for px in img.data.iter() {
|
||||
let a = px[3];
|
||||
if a <= eps || a >= 1.0 - eps {
|
||||
continue;
|
||||
}
|
||||
partial += 1;
|
||||
for c in &px[..3] {
|
||||
// The file's colour (the decoder premultiplied it as straight).
|
||||
let file = c / a;
|
||||
black &= file <= a + eps;
|
||||
white &= file >= 1.0 - a - eps;
|
||||
}
|
||||
}
|
||||
if partial == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(if black {
|
||||
(AlphaMode::Premultiplied, [0.0; 3])
|
||||
} else if white {
|
||||
(AlphaMode::Premultiplied, [1.0; 3])
|
||||
} else {
|
||||
(AlphaMode::Straight, f.premul_color)
|
||||
})
|
||||
}
|
||||
|
||||
fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = items_p(s, p);
|
||||
if items.is_empty() {
|
||||
return Err(bad("file.interpretFootage", "select footage"));
|
||||
}
|
||||
let rate = f_p(p, "frameRate").map(FrameRate::from_f64);
|
||||
let alpha = match str_p(p, "alpha") {
|
||||
Some("straight") => Some(AlphaMode::Straight),
|
||||
Some("premultiplied") => Some(AlphaMode::Premultiplied),
|
||||
Some("ignore") => Some(AlphaMode::Ignore),
|
||||
Some(a) => return Err(bad("file.interpretFootage", format!("alpha: straight|premultiplied|ignore, not `{a}`"))),
|
||||
None => None,
|
||||
};
|
||||
let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let par = f_p(p, "pixelAspect");
|
||||
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
|
||||
let profile = match str_p(p, "colorProfile") {
|
||||
Some("auto" | "none" | "") => Some(None),
|
||||
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
|
||||
Some(cs) => Some(Some(cs)),
|
||||
None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let it = Interpretation::parse(p, "file.interpretFootage")?;
|
||||
let guesses: Vec<Option<(AlphaMode, [f32; 3])>> = items
|
||||
.iter()
|
||||
.map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) {
|
||||
(true, Some(ItemKind::Footage(f))) => guess_alpha(s, *i, f),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
s.edit("Interpret Footage", None, |proj, _| {
|
||||
for i in &items {
|
||||
for (i, g) in items.iter().zip(&guesses) {
|
||||
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
|
||||
if let Some(r) = rate {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
}
|
||||
f.frame_rate = r;
|
||||
}
|
||||
if let Some(a) = alpha {
|
||||
f.alpha = a;
|
||||
}
|
||||
if let Some(l) = loops {
|
||||
f.loop_count = l;
|
||||
}
|
||||
if let Some(x) = par {
|
||||
f.pixel_aspect = x;
|
||||
}
|
||||
if let Some(c) = profile {
|
||||
f.color_profile = c;
|
||||
}
|
||||
it.apply(f, *g);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
let out: Vec<Value> = items
|
||||
.iter()
|
||||
.filter_map(|i| match s.project.item(*i).map(|x| &x.kind) {
|
||||
Some(ItemKind::Footage(f)) => Some(json!({
|
||||
"item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha,
|
||||
"fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(),
|
||||
"linearLight": f.linear_light,
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"items": out}))
|
||||
}
|
||||
|
||||
fn remember_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -512,6 +636,10 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
f.premul_color = src.premul_color;
|
||||
f.pixel_aspect = src.pixel_aspect;
|
||||
f.loop_count = src.loop_count;
|
||||
f.fields = src.fields;
|
||||
f.invert_alpha = src.invert_alpha;
|
||||
f.linear_light = src.linear_light;
|
||||
f.color_profile = src.color_profile;
|
||||
if src.native_rate.is_some() {
|
||||
if f.native_rate.is_none() {
|
||||
f.native_rate = Some(f.frame_rate);
|
||||
@@ -699,7 +827,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}",
|
||||
"{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
|
||||
@@ -53,7 +53,7 @@ fn new_solid_like(s: &mut Session, p: &Value, adjustment: bool) -> Result<Value>
|
||||
let name = str_p(p, "name").map(str::to_string).unwrap_or(default_name);
|
||||
let id = s.edit(if adjustment { "New Adjustment Layer" } else { "New Solid" }, None, |proj, st| {
|
||||
let folder = solids_folder(proj);
|
||||
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
|
||||
let sid = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: comp.pixel_aspect }));
|
||||
let mut l = build::layer(proj, &comp, &name, LayerSource::Solid { item: sid }, (w, h), None);
|
||||
if adjustment {
|
||||
l.switches.adjustment = true;
|
||||
@@ -239,6 +239,9 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds));
|
||||
(LayerSource::Footage { item }, (f.width, f.height), d)
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Data => {
|
||||
return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData"));
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
|
||||
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time")}));
|
||||
}
|
||||
@@ -519,7 +522,8 @@ fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed
|
||||
// in the new parent's space (Position, Rotation, Scale), unless `compensate: false`.
|
||||
let compensate = b_p(p, "compensate").unwrap_or(true);
|
||||
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref() };
|
||||
let ectx =
|
||||
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
|
||||
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
|
||||
};
|
||||
|
||||
@@ -126,7 +126,7 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
|
||||
return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer"));
|
||||
}
|
||||
// What each layer shows now (honouring an existing remap) — evaluated before the edit.
|
||||
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref() };
|
||||
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
|
||||
.layers
|
||||
.iter()
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
mod anim;
|
||||
pub mod anim_tools;
|
||||
mod animation;
|
||||
mod camera_cmds;
|
||||
mod comp;
|
||||
mod comp_more;
|
||||
mod create;
|
||||
mod edit;
|
||||
mod effect;
|
||||
pub mod essential;
|
||||
pub mod expr_tools;
|
||||
mod file;
|
||||
mod file_more;
|
||||
pub(crate) mod file_more;
|
||||
mod focus;
|
||||
mod frontend;
|
||||
mod help;
|
||||
@@ -30,6 +34,7 @@ mod paths;
|
||||
mod project_items;
|
||||
mod prop;
|
||||
mod prop_groups;
|
||||
mod proxy;
|
||||
pub mod puppet;
|
||||
mod query;
|
||||
mod render_queue;
|
||||
@@ -127,6 +132,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(help::specs());
|
||||
v.extend(query::specs());
|
||||
v.extend(layer_menu::specs());
|
||||
v.extend(create::specs());
|
||||
v.extend(animation::specs());
|
||||
v.extend(view::specs());
|
||||
v.extend(viewer_cmds::specs());
|
||||
@@ -141,16 +147,25 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(track::specs());
|
||||
v.extend(mask_interp::specs());
|
||||
v.extend(warp_cmds::specs());
|
||||
v.extend(camera_cmds::specs());
|
||||
v.extend(roto_cmds::specs());
|
||||
v.extend(paint::specs());
|
||||
v.extend(puppet::specs());
|
||||
v.extend(liquify::specs());
|
||||
v.extend(settings::specs());
|
||||
v.extend(essential::specs());
|
||||
v.extend(expr_tools::specs());
|
||||
v.extend(proxy::specs());
|
||||
v.extend(stubs::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
/// Set `footage` as the proxy of `item` (File ▸ Create Proxy's post-render action).
|
||||
pub(crate) fn proxy_set(s: &mut Session, item: ItemId, footage: effectcraft_project::Footage) -> Result<()> {
|
||||
proxy::set_proxy_footage(s, &[item], footage)
|
||||
}
|
||||
|
||||
/// (id, name) of the text animation presets.
|
||||
pub fn text_preset_list() -> Vec<(String, String)> {
|
||||
text_anim::preset_names()
|
||||
|
||||
@@ -557,7 +557,8 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fd = comp.frame_duration();
|
||||
let mut t = layer.in_point;
|
||||
while t < layer.out_point {
|
||||
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref() };
|
||||
let ctx =
|
||||
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
|
||||
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
|
||||
t += fd;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Proxies (File ▸ Create Proxy / Set Proxy / Interpret Footage ▸ Proxy): a stand-in file for
|
||||
//! a footage item or a composition. The Project panel's proxy switch (`file.useProxy`) turns
|
||||
//! it on or off per item; renders follow Render Settings ▸ Proxy Use (the viewer uses each
|
||||
//! item's switch).
|
||||
|
||||
use effectcraft_project::render_queue::{Channels, OutputFormat, OutputModule, PostRenderAction, ProResProfile, RenderQuality, RenderQueueItem, TimeSpan};
|
||||
use effectcraft_project::{Footage, ItemId, ItemKind, Proxy};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_project_selection, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// Footage items and comps a proxy command acts on: `items`/`item`, else the Project panel
|
||||
/// selection.
|
||||
fn targets(s: &Session, p: &Value) -> Vec<ItemId> {
|
||||
let ids: Vec<ItemId> = match p.get("items").or(p.get("item")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(Value::as_u64).map(ItemId).collect(),
|
||||
Some(Value::Number(n)) => n.as_u64().map(ItemId).into_iter().collect(),
|
||||
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).into_iter().collect(),
|
||||
_ => s.state.project_selection.clone(),
|
||||
};
|
||||
ids.into_iter().filter(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_) | ItemKind::Comp(_)))).collect()
|
||||
}
|
||||
|
||||
fn has_targets(s: &Session) -> std::result::Result<(), String> {
|
||||
has_project_selection(s)?;
|
||||
if targets(s, &Value::Null).is_empty() { Err("select footage or a composition in the Project panel".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn has_proxy(s: &Session) -> std::result::Result<(), String> {
|
||||
has_targets(s)?;
|
||||
if targets(s, &Value::Null).iter().any(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("the selected items have no proxy".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe a proxy file through the importer.
|
||||
fn probe(s: &Session, path: &str, cmd: &str) -> Result<Footage> {
|
||||
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
|
||||
let f = importer.probe(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
if !f.has_video {
|
||||
return Err(bad(cmd, format!("{path} has no video to use as a proxy")));
|
||||
}
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// Attach `footage` as the proxy of `items` (Use Proxy on). One undo step.
|
||||
pub(crate) fn set_proxy_footage(s: &mut Session, items: &[ItemId], footage: Footage) -> Result<()> {
|
||||
s.edit("Set Proxy", None, |proj, _| {
|
||||
for i in items {
|
||||
if let Some(it) = proj.item_mut(*i) {
|
||||
it.proxy = Some(Box::new(Proxy { footage: footage.clone(), enabled: true }));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.setProxy";
|
||||
let items = targets(s, p);
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "select footage or a composition"));
|
||||
}
|
||||
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path`"))?.to_string();
|
||||
let f = probe(s, &path, C)?;
|
||||
let (w, h) = (f.width, f.height);
|
||||
set_proxy_footage(s, &items, f)?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "path": path, "width": w, "height": h}))
|
||||
}
|
||||
|
||||
fn set_proxy_none(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let items = targets(s, p);
|
||||
s.edit("Set Proxy None", None, |proj, _| {
|
||||
for i in &items {
|
||||
if let Some(it) = proj.item_mut(*i) {
|
||||
it.proxy = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// The Project panel's proxy switch: use the proxy (filled box) or the full-resolution source.
|
||||
fn use_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.useProxy";
|
||||
let items: Vec<ItemId> = targets(s, p).into_iter().filter(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())).collect();
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "the selected items have no proxy"));
|
||||
}
|
||||
let first = s.project.item(items[0]).and_then(|x| x.proxy.as_ref()).is_some_and(|px| px.enabled);
|
||||
let on = b_p(p, "on").unwrap_or(!first);
|
||||
s.edit(if on { "Use Proxy" } else { "Use Full Resolution" }, None, |proj, _| {
|
||||
for i in &items {
|
||||
if let Some(px) = proj.item_mut(*i).and_then(|x| x.proxy.as_mut()) {
|
||||
px.enabled = on;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>(), "on": on}))
|
||||
}
|
||||
|
||||
/// File ▸ Interpret Footage ▸ Proxy: Interpret Footage settings for the proxy file.
|
||||
fn interpret_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.interpretProxy";
|
||||
let items: Vec<ItemId> = targets(s, p).into_iter().filter(|i| s.project.item(*i).is_some_and(|x| x.proxy.is_some())).collect();
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "the selected items have no proxy"));
|
||||
}
|
||||
let it = super::file_more::Interpretation::parse(p, C)?;
|
||||
let guesses: Vec<_> = items
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let px = s.project.item(*i).and_then(|x| x.proxy.as_ref());
|
||||
match (p.get("alpha").and_then(Value::as_str) == Some("guess") || b_p(p, "guessAlpha") == Some(true), px) {
|
||||
(true, Some(px)) => super::file_more::guess_alpha(s, *i, &px.footage),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
s.edit("Interpret Proxy", None, |proj, _| {
|
||||
for (i, g) in items.iter().zip(&guesses) {
|
||||
if let Some(px) = proj.item_mut(*i).and_then(|x| x.proxy.as_mut()) {
|
||||
it.apply(&mut px.footage, *g);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"items": items.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// File ▸ Create Proxy ▸ Still / Movie: queue a half-resolution render of the composition whose
|
||||
/// post-render action sets the result as its proxy.
|
||||
fn create_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.createProxy";
|
||||
let cid = match p.get("comp").or(p.get("item")) {
|
||||
Some(_) => {
|
||||
let q = json!({"comp": p.get("comp").or(p.get("item")).cloned()});
|
||||
super::comp_id(s, &q)?
|
||||
}
|
||||
None => s
|
||||
.state
|
||||
.project_selection
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|i| s.project.comp(*i).is_some())
|
||||
.or(s.active_comp_id())
|
||||
.ok_or_else(|| bad(C, "select a composition"))?,
|
||||
};
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let still = match str_p(p, "kind").unwrap_or("movie") {
|
||||
"still" => true,
|
||||
"movie" => false,
|
||||
k => return Err(bad(C, format!("kind: still|movie, not `{k}`"))),
|
||||
};
|
||||
let mut it = RenderQueueItem::new(0, cid);
|
||||
it.settings.quality = RenderQuality::Draft;
|
||||
it.settings.resolution = p.get("resolution").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.05, 1.0);
|
||||
if still {
|
||||
// One frame at the current time (the comp's poster frame when not the active comp).
|
||||
let t = if s.active_comp_id() == Some(cid) { s.time() } else { comp.poster_time };
|
||||
it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() };
|
||||
it.output = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
it.output.output = "[compName]_proxy_[#####].png".into();
|
||||
} else {
|
||||
it.settings.time_span = TimeSpan::LengthOfComp;
|
||||
it.output = OutputModule::for_format(OutputFormat::ProRes);
|
||||
it.output.prores_profile = ProResProfile::P4444;
|
||||
it.output.output = "[compName]_proxy.[fileExtension]".into();
|
||||
}
|
||||
it.output.channels = Channels::Rgba;
|
||||
if let Some(o) = str_p(p, "path").or(str_p(p, "output")) {
|
||||
it.output.output = o.to_string();
|
||||
}
|
||||
it.post_render = PostRenderAction::SetProxy;
|
||||
let id = s.edit("Create Proxy", None, |proj, _| {
|
||||
it.id = proj.render_queue.iter().map(|i| i.id).max().unwrap_or(0) + 1;
|
||||
let id = it.id;
|
||||
proj.render_queue.push(it);
|
||||
Ok(id)
|
||||
})?;
|
||||
let path = s.project.render_queue.last().and_then(|i| s.resolve_output(i));
|
||||
Ok(json!({"item": id, "comp": cid.0, "kind": if still { "still" } else { "movie" }, "output": path, "time": Tick::ZERO.seconds()}))
|
||||
}
|
||||
|
||||
fn has_comp_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.state.project_selection.iter().any(|i| s.project.comp(*i).is_some()) || s.active_comp_id().is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select a composition".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"file.createProxy",
|
||||
"Create Proxy",
|
||||
[],
|
||||
None,
|
||||
"{kind: still|movie, comp?|item?, path?|output? (template), resolution? (default 0.5)}",
|
||||
has_comp_selection,
|
||||
create_proxy
|
||||
),
|
||||
cmd!("file.setProxy", "File...", ["File", "Set Proxy"], None, "{path, items?|item?}", has_targets, set_proxy),
|
||||
cmd!("file.setProxyNone", "None", ["File", "Set Proxy"], None, "{items?|item?}", has_proxy, set_proxy_none),
|
||||
cmd!("file.useProxy", "Use Proxy", [], None, "{items?|item?, on?}", has_proxy, use_proxy),
|
||||
cmd!(
|
||||
"file.interpretProxy",
|
||||
"Proxy...",
|
||||
["File", "Interpret Footage"],
|
||||
None,
|
||||
"{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}",
|
||||
has_proxy,
|
||||
interpret_proxy
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -29,7 +29,7 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
|
||||
let fx = layer.effects()?;
|
||||
let index = fx.groups().position(|g| g.uid == fx_uid)?;
|
||||
let g = fx.groups().nth(index)?;
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
|
||||
r.expr = s.expr.as_deref();
|
||||
r.cache = Some(&s.layer_cache);
|
||||
|
||||
@@ -104,6 +104,10 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
|
||||
{
|
||||
return Err(bad(cmd, "end must be after start"));
|
||||
}
|
||||
if let Some(u) = str_p(p, "proxyUse") {
|
||||
rs.proxy_use = effectcraft_project::render_queue::ProxyUse::parse(u).ok_or_else(|| bad(cmd, "proxyUse: current|all|comp|none"))?;
|
||||
any = true;
|
||||
}
|
||||
match p.get("frameRate") {
|
||||
Some(Value::Null) => {
|
||||
rs.frame_rate = None;
|
||||
@@ -131,7 +135,7 @@ fn apply_settings(rs: &mut RenderSettings, p: &Value, cmd: &str) -> Result<bool>
|
||||
fn apply_output(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
|
||||
let mut any = false;
|
||||
if let Some(f) = str_p(p, "format") {
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
|
||||
om.set_format(f);
|
||||
any = true;
|
||||
}
|
||||
@@ -256,7 +260,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.comp_for_queue(p).ok_or(EngineError::NoComp)?;
|
||||
let mut it = RenderQueueItem::new(0, cid);
|
||||
if let Some(f) = str_p(p, "format") {
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("renderQueue.add", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
|
||||
it.output = OutputModule::for_format(f);
|
||||
}
|
||||
apply_settings(&mut it.settings, p, "renderQueue.add")?;
|
||||
@@ -434,7 +438,7 @@ fn add_output_module(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = it.extra_outputs.len() + 2;
|
||||
let mut om = it.output.clone();
|
||||
if let Some(f) = str_p(p, "format") {
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|png|jpeg|tiff|exr|gif"))?;
|
||||
let f = OutputFormat::from_name(f).ok_or_else(|| bad("render.addOutputModule", "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
|
||||
om = OutputModule::for_format(f);
|
||||
}
|
||||
if str_p(p, "output").is_none() && str_p(p, "path").is_none() {
|
||||
@@ -509,7 +513,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add to Render Queue",
|
||||
["Composition"],
|
||||
Some("Cmd+M"),
|
||||
"{comp?: id|name, format?: h264|prores|png|jpeg|tiff|exr|gif, output?: path|template, quality?: best|draft|1-100 (jpeg), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
|
||||
"{comp?: id|name, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none, channels?: rgb|rgba, bitrate?: kbps, proresProfile?: proxy|lt|standard|hq|4444|4444xq, audio?: auto|on|off, sampleRate?, loop?: bool}",
|
||||
can_add,
|
||||
add
|
||||
),
|
||||
@@ -520,7 +524,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Render Settings...",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool}",
|
||||
"{item?|index?, quality?: best|draft, resolution?: full|half|third|quarter|scale, timeSpan?: workArea|comp|custom, start?: s, end?: s, frameRate?: fps|null, motionBlur?: bool, skipExisting?: bool, proxyUse?: current|all|comp|none}",
|
||||
has_items,
|
||||
set_render_settings
|
||||
),
|
||||
|
||||
@@ -239,7 +239,7 @@ fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let root = s.default_autosave_root();
|
||||
let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
|
||||
let latest = crate::autosave::latest_in(s.file_ops(), &s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
|
||||
Ok(json!({
|
||||
"lastAutoSave": s.autosave.last_path,
|
||||
"latestOnDisk": latest,
|
||||
|
||||
@@ -17,18 +17,11 @@ macro_rules! stub {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
// Render queue / export (export milestone).
|
||||
stub!("file.createProxy", "Create Proxy", [], "{kind: still|movie}"),
|
||||
// Proxies (no proxy model yet).
|
||||
stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"),
|
||||
stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"),
|
||||
stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"),
|
||||
// Layer ▸ New (3D / content-aware fill).
|
||||
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
|
||||
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
|
||||
// Time (timeline milestone).
|
||||
stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"),
|
||||
stub!("layer.create", "Create", [], "{op}"),
|
||||
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
|
||||
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
|
||||
// Cameras / lights / materials / 3D views (3D milestone).
|
||||
@@ -46,7 +39,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// Keyframes / text / tracking.
|
||||
stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"),
|
||||
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),
|
||||
stub!("track.camera", "Track Camera", ["Animation"], "{}"),
|
||||
// Workspaces and panels that don't exist yet.
|
||||
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
|
||||
]
|
||||
|
||||
@@ -393,7 +393,7 @@ fn get_3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default();
|
||||
let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO);
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx));
|
||||
let f = cam.forward();
|
||||
let lights: Vec<Value> = comp
|
||||
@@ -535,7 +535,7 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// through each layer's world matrix; cameras and lights contribute their position).
|
||||
fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec<Vec3> {
|
||||
let Some(comp) = s.project.comp(cid) else { return vec![] };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref() };
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None };
|
||||
let mut out = vec![];
|
||||
for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) {
|
||||
let m = ctx.world_matrix(l);
|
||||
|
||||
Reference in New Issue
Block a user