Files
effectcraft/crates/engine/src/commands/file_more.rs
T
aiongg 816ca95800 Footage: follow stored footage paths only on this computer or the project's share
Footage paths stored in a project are resolved through craft-docpath against
the project file when it is opened or imported: relative paths are taken from
the project's folder, and an item whose path is on another computer, names a
device or is a non-file URL is marked missing without being looked at. The
footage check, the changed-on-disk scan, Reload Footage, Relink, Edit Original
and Reveal in Finder resolve the path again before touching the file, and the
Project panel, Metadata panel and waveforms read no file details of missing
footage.

Lottie image assets are resolved against the Lottie file; one on another
computer is skipped with a warning. Files a 3D model names are read only when
local or on the model's own share.

Reveal in Finder refuses a folder that would open as an app or other bundle.

Files imported or relinked in this session are opened as given.
2026-10-16 17:43:58 +00:00

1091 lines
48 KiB
Rust

//! File menu, part two: folders, close, save a copy, import placeholders/solids, New Comp from
//! Selection, Dependencies (collect / consolidate / remove unused / reduce / find missing),
//! scripts, footage interpretation, replace/reload footage, reveal in Finder.
use std::collections::{BTreeMap, BTreeSet};
use effectcraft_color::Label;
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, f_p, has_comp, has_project_selection, str_p};
use crate::sequence::SequenceOptions;
use crate::{EngineError, Result, Session, cmd};
fn has_footage_selection(s: &Session) -> std::result::Result<(), String> {
if selected_footage(s).is_empty() { Err("select footage in the Project panel".into()) } else { Ok(()) }
}
fn has_interpretation(s: &Session) -> std::result::Result<(), String> {
has_footage_selection(s)?;
if s.state.interpretation.is_none() { Err("no interpretation remembered".into()) } else { Ok(()) }
}
fn has_items(s: &Session) -> std::result::Result<(), String> {
if s.project.items.is_empty() { Err("the project is empty".into()) } else { Ok(()) }
}
fn has_comp_selection(s: &Session) -> std::result::Result<(), String> {
if s.state.project_selection.iter().any(|i| s.project.comp(*i).is_some()) { Ok(()) } else { Err("select compositions in the Project panel".into()) }
}
/// Selected project items that are footage (or the source of the selected layer).
fn selected_footage(s: &Session) -> Vec<ItemId> {
let mut v: Vec<ItemId> =
s.state.project_selection.iter().copied().filter(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_)))).collect();
if v.is_empty()
&& let Some(c) = s.active_comp()
{
v = s
.state
.selected_layers
.iter()
.filter_map(|l| c.layer(*l))
.filter_map(|l| if let LayerSource::Footage { item } = l.source { Some(item) } else { None })
.collect();
}
v
}
fn items_p(s: &Session, p: &Value) -> 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(),
_ => selected_footage(s),
}
}
fn new_folder(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name").unwrap_or("Untitled Folder").to_string();
let parent = p.get("parent").and_then(Value::as_u64).map(ItemId).filter(|i| s.project.item(*i).is_some_and(|x| x.is_folder()));
let id = s.edit("New Folder", None, |proj, st| {
let id = proj.add_item(&name, Label::Yellow, parent, ItemKind::Folder);
st.project_selection = vec![id];
Ok(id)
})?;
Ok(json!({"item": id.0}))
}
fn close(s: &mut Session, p: &Value) -> Result<Value> {
s.execute("comp.close", p.clone())
}
fn close_project(s: &mut Session, _: &Value) -> Result<Value> {
s.replace_project(Project::default(), None);
Ok(Value::Null)
}
fn save_copy(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopy", "missing `path`"))?;
let json = super::file::file_text(s, path)?;
s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
Ok(json!({"path": path, "bytes": json.len()}))
}
fn rate_of(p: &Value) -> FrameRate {
f_p(p, "frameRate").map(|f| limits::frame_rate(FrameRate::from_f64(f), FrameRate::FPS_29_97)).unwrap_or(FrameRate::FPS_29_97)
}
fn placeholder_footage(p: &Value) -> Footage {
let w = p.get("width").and_then(Value::as_u64).map_or(1920, limits::size);
let h = p.get("height").and_then(Value::as_u64).map_or(1080, limits::size);
Footage {
path: String::new(),
kind: FootageKind::Video,
width: w,
height: h,
pixel_aspect: 1.0,
frame_rate: rate_of(p),
native_rate: None,
duration: Tick::from_seconds_f64(f_p(p, "duration").unwrap_or(30.0)),
has_video: true,
has_audio: false,
alpha: AlphaMode::Straight,
premul_color: [0.0; 3],
loop_count: 1,
codec: "Placeholder".into(),
missing: true,
sequence: vec![],
color_profile: None,
..Default::default()
}
}
fn import_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "name").unwrap_or("Placeholder").to_string();
let f = placeholder_footage(p);
let id = s.edit("Import Placeholder", None, |proj, st| {
let id = proj.add_item(&name, Label::Aqua, None, ItemKind::Footage(f));
st.project_selection = vec![id];
Ok(id)
})?;
Ok(json!({"item": id.0}))
}
fn solid_from(p: &Value, s: &Session) -> Solid {
let (cw, ch) = s.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080));
let color = match p.get("color") {
Some(Value::String(h)) => effectcraft_color::Rgba::from_hex(h).map(|c| [c.r, c.g, c.b]),
Some(Value::Array(a)) => Some([0, 1, 2].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0) as f32)),
_ => None,
}
.unwrap_or([0.5, 0.5, 0.5]);
Solid {
color,
width: p.get("width").and_then(Value::as_u64).map(limits::size).unwrap_or(cw).max(1),
height: p.get("height").and_then(Value::as_u64).map(limits::size).unwrap_or(ch).max(1),
pixel_aspect: 1.0,
}
}
fn import_solid(s: &mut Session, p: &Value) -> Result<Value> {
let so = solid_from(p, s);
let name = str_p(p, "name").unwrap_or("Solid").to_string();
let id = s.edit("Import Solid", None, |proj, st| {
let folder = proj.folder_named("Solids").unwrap_or_else(|| proj.add_item("Solids", Label::Yellow, None, ItemKind::Folder));
let id = proj.add_item(&name, Label::Red, Some(folder), ItemKind::Solid(so));
st.project_selection = vec![id];
Ok(id)
})?;
Ok(json!({"item": id.0}))
}
/// File ▸ New Comp from Selection. Each selected item becomes a composition of its size, pixel
/// aspect, frame rate and duration holding it as a layer; with `single`, one composition holds
/// them all (first selected on top), with the settings of item `dimensionsFrom` (an index into
/// the selection, default 0), as long as the longest item, or with `sequence` the layers one
/// after another (Sequence Layers: `overlap`, `overlapDuration` seconds, `transition`).
/// `duration`: seconds for stills (default: Settings ▸ Import ▸ Still Footage when it is a
/// duration, else 10 s).
/// `addToRenderQueue` queues the new compositions. One undo step.
fn new_comp_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
let items: Vec<ItemId> = s.state.project_selection.iter().copied().filter(|i| s.project.item(*i).is_some_and(|x| !x.is_folder())).collect();
if items.is_empty() {
return Err(bad("file.newCompFromSelection", "select footage in the Project panel"));
}
let single = b_p(p, "single").unwrap_or(false) && items.len() > 1;
let from = p.get("dimensionsFrom").and_then(Value::as_u64).unwrap_or(0) as usize;
if single && from >= items.len() {
return Err(bad("file.newCompFromSelection", format!("`dimensionsFrom` must be below {}", items.len())));
}
let still = f_p(p, "duration")
.or((s.prefs.import.still_footage == "seconds").then_some(s.prefs.import.still_seconds))
.filter(|d| d.is_finite() && *d > 0.0)
.unwrap_or(10.0);
// Layer ▸ Keyframe Assistant ▸ Sequence Layers' parameters.
let sequence = b_p(p, "sequence").unwrap_or(false).then(|| {
json!({
"overlap": b_p(p, "overlap").unwrap_or(false),
"duration": f_p(p, "overlapDuration").filter(|d| d.is_finite() && *d >= 0.0).unwrap_or(1.0),
"transition": str_p(p, "transition").unwrap_or("off"),
})
});
let queue = b_p(p, "addToRenderQueue").unwrap_or(false);
let proj = s.project.clone();
let duration = |i: ItemId| proj.item(i).and_then(|it| it.duration()).filter(|d| *d > Tick::ZERO).unwrap_or(Tick::from_seconds_f64(still));
let groups: Vec<Vec<ItemId>> = if single { vec![items.clone()] } else { items.iter().map(|i| vec![*i]).collect() };
super::app_more::grouped(s, "New Comp from Selection", |s| {
let mut made = vec![];
for g in groups {
let lead = g.get(if single { from } else { 0 }).and_then(|i| proj.item(*i)).ok_or(EngineError::NoComp)?;
let (w, h) = lead.dimensions().unwrap_or((1920, 1080));
let rate = lead.frame_rate().unwrap_or(FrameRate::FPS_29_97);
let pixel_aspect = match &lead.kind {
ItemKind::Footage(f) => f.pixel_aspect,
ItemKind::Solid(so) => so.pixel_aspect,
ItemKind::Comp(c) => c.pixel_aspect,
ItemKind::Folder => 1.0,
};
let overlap =
sequence.as_ref().filter(|q| q["overlap"] == true).map_or(Tick::ZERO, |q| Tick::from_seconds_f64(q["duration"].as_f64().unwrap_or(1.0)));
let dur = match &sequence {
Some(_) if g.len() > 1 => g.iter().map(|i| duration(*i)).fold(Tick::ZERO, |a, d| a + d) - Tick(overlap.0.saturating_mul(g.len() as i64 - 1)),
_ => g.iter().map(|i| duration(*i)).max().unwrap_or(Tick::from_seconds_f64(still)),
}
.max(rate.frame_duration());
let name = lead.name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(lead.name.clone());
let c = Comp { pixel_aspect, ..Comp::new(w.max(1), h.max(1), rate, dur) };
let cid = s.edit("New Comp from Selection", None, |proj, st| {
// A name another item already has gets the next free number, as Duplicate gives
// ("clip", "clip 2", "clip 3"). The comp's own footage and solid sources don't
// count, so an extensionless one still names its first comp; a source comp does.
let name = proj.unique_item_name(&name, |i| g.contains(&i.id) && !matches!(i.kind, ItemKind::Comp(_)));
let cid = proj.add_item(&name, Label::Sandstone, None, ItemKind::Comp(c.into()));
st.project_selection = vec![cid];
Ok(cid)
})?;
s.open_comp(cid);
// Added bottom first, so the first selected ends up on top.
let mut layers = vec![];
for item in g.iter().rev() {
layers.push(s.execute("layer.addItem", json!({"comp": cid.0, "item": item.0, "duration": still}))?["layer"].clone());
}
layers.reverse();
if let Some(q) = &sequence
&& layers.len() > 1
{
let mut q = q.clone();
q["comp"] = json!(cid.0);
q["layers"] = json!(layers);
s.execute("layer.sequence", q)?;
}
if queue {
s.execute("renderQueue.add", json!({"comp": cid.0}))?;
}
made.push(cid.0);
}
Ok(json!({"comps": made}))
})
}
// ---------------------------------------------------------------- dependencies
/// Items used (transitively) by `roots`.
fn used_items(proj: &Project, roots: &[ItemId]) -> BTreeSet<ItemId> {
let mut seen = BTreeSet::new();
let mut stack: Vec<ItemId> = roots.to_vec();
while let Some(i) = stack.pop() {
if !seen.insert(i) {
continue;
}
if let Some(c) = proj.comp(i) {
stack.extend(c.layers.iter().filter_map(|l| l.source.item()));
}
// Parent folders stay so the structure is kept.
if let Some(parent) = proj.item(i).and_then(|x| x.parent) {
stack.push(parent);
}
}
seen
}
fn referenced_sources(proj: &Project) -> BTreeSet<ItemId> {
proj.comps().flat_map(|(_, c)| c.layers.iter().filter_map(|l| l.source.item())).collect()
}
fn remove_unused(s: &mut Session, _: &Value) -> Result<Value> {
let used = referenced_sources(&s.project);
let n = s.edit("Remove Unused Footage", None, |proj, _| {
let before = proj.items.len();
proj.items.retain(|id, it| !matches!(it.kind, ItemKind::Footage(_) | ItemKind::Solid(_)) || used.contains(id));
Ok(before - proj.items.len())
})?;
s.sanitize_state();
s.toast(format!("Removed {n} unused footage item(s)"));
Ok(json!({"removed": n}))
}
fn consolidate(s: &mut Session, _: &Value) -> Result<Value> {
// Footage with the same file and interpretation collapses into the first such item.
let mut first: BTreeMap<String, ItemId> = BTreeMap::new();
let mut remap: BTreeMap<ItemId, ItemId> = BTreeMap::new();
for (id, it) in &s.project.items {
if let ItemKind::Footage(f) = &it.kind
&& !f.path.is_empty()
{
let key = serde_json::to_string(f).unwrap_or_default();
match first.get(&key) {
Some(keep) => {
remap.insert(*id, *keep);
}
None => {
first.insert(key, *id);
}
}
}
}
let n = remap.len();
s.edit("Consolidate All Footage", None, |proj, _| {
let ids: Vec<ItemId> = proj.comps().map(|(id, _)| *id).collect();
for cid in ids {
if let Some(c) = proj.comp_mut(cid) {
for l in &mut c.layers {
if let LayerSource::Footage { item } = &mut l.source
&& let Some(k) = remap.get(item)
{
*item = *k;
}
}
}
}
proj.items.retain(|id, _| !remap.contains_key(id));
Ok(())
})?;
s.sanitize_state();
s.toast(format!("Consolidated {n} duplicate footage item(s)"));
Ok(json!({"removed": n}))
}
fn reduce(s: &mut Session, _: &Value) -> Result<Value> {
let roots: Vec<ItemId> = s.state.project_selection.iter().copied().filter(|i| s.project.comp(*i).is_some()).collect();
let keep = used_items(&s.project, &roots);
let n = s.edit("Reduce Project", None, |proj, _| {
let before = proj.items.len();
proj.items.retain(|id, _| keep.contains(id));
Ok(before - proj.items.len())
})?;
s.sanitize_state();
s.toast(format!("Removed {n} item(s)"));
Ok(json!({"removed": n}))
}
fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
let what = str_p(p, "what").unwrap_or("footage");
match what {
"footage" => {
let ids: Vec<ItemId> = s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if f.missing)).map(|i| i.id).collect();
s.state.project_selection = ids.clone();
s.toast(format!("{} missing footage item(s)", ids.len()));
Ok(json!({"items": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
"effects" => {
let mut found = vec![];
for (cid, c) in s.project.comps() {
for l in &c.layers {
for g in l.effects().into_iter().flat_map(|fx| fx.groups()) {
if let effectcraft_project::GroupKind::Effect { effect } = &g.kind
&& crate::effects::find(effect).is_none()
{
found.push(json!({"comp": cid.0, "layer": l.id.0, "effect": effect}));
}
}
}
}
s.toast(format!("{} missing effect(s)", found.len()));
Ok(json!({"effects": found}))
}
"fonts" => {
let mut found = vec![];
for (cid, c) in s.project.comps() {
for l in &c.layers {
if let Some(pr) = l.props.prop("text/sourceText")
&& let Some(doc) = pr.value.as_text()
&& effectcraft_text::resolve(&doc.font, &doc.style).missing
{
found.push(json!({"comp": cid.0, "layer": l.id.0, "font": doc.font}));
}
}
}
s.toast(format!("{} layer(s) with missing fonts", found.len()));
Ok(json!({"fonts": found}))
}
w => Err(bad("file.findMissing", format!("what: footage|effects|fonts, not `{w}`"))),
}
}
fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
let folder = str_p(p, "folder").ok_or_else(|| bad("file.collectFiles", "missing `folder`"))?.trim_end_matches('/').to_string();
// Collect Files makes its folder (and the footage folder inside it).
#[cfg(not(target_arch = "wasm32"))]
std::fs::create_dir_all(format!("{folder}/(Footage)")).map_err(|e| EngineError::Other(format!("cannot create {folder}: {e}")))?;
let mut proj = (*s.project).clone();
let mut copied = 0;
let mut errors = vec![];
let mut used_names = BTreeSet::new();
for it in proj.items.values_mut() {
let ItemKind::Footage(f) = &mut it.kind else { continue };
if f.path.is_empty() {
continue;
}
let files: Vec<String> = if f.sequence.is_empty() { vec![f.path.clone()] } else { f.sequence.clone() };
let mut new_paths = vec![];
for src in &files {
let mut name = std::path::Path::new(src).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "footage".into());
let mut k = 2;
while !used_names.insert(name.clone()) {
name = format!("{k}_{name}");
k += 1;
}
let dst = format!("{folder}/(Footage)/{name}");
match s.services.read_file(src).and_then(|bytes| s.services.write_file(&dst, &bytes)) {
Ok(()) => copied += 1,
Err(e) => errors.push(format!("{src}: {e}")),
}
new_paths.push(dst);
}
if f.sequence.is_empty() {
f.path = new_paths.remove(0);
} else {
f.path = new_paths.first().cloned().unwrap_or_default();
f.sequence = new_paths;
}
}
let stem = s
.path
.as_deref()
.and_then(|p| std::path::Path::new(p).file_stem())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled Project".into());
let out = format!("{folder}/{stem}.ecproj");
s.services.write_file(&out, proj.to_json().as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {out}: {e}")))?;
s.toast(format!("Collected {copied} file(s) into {folder}"));
Ok(json!({"project": out, "copied": copied, "errors": errors}))
}
// ---------------------------------------------------------------- scripts
/// File ▸ Scripts ▸ Run Script File…: `.jsx` / `.js` files run as JavaScript against the After
/// Effects-style object model ([`Session::script`]); anything else is a command script: a JSON
/// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's
/// `{"method": "engine.execute", "params": {"id", "params"}}`).
fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
// File ▸ Scripts ▸ <installed or sample script>.
if let Some(name) = str_p(p, "name")
&& p.get("path").is_none()
&& p.get("steps").is_none()
{
let name = name.to_string();
return super::scripts::run_named(s, &name);
}
if let Some(path) = str_p(p, "path")
&& p.get("steps").is_none()
&& is_js_script(path)
{
if is_jsxbin(path) {
return Err(bad("file.runScript", JSXBIN));
}
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let code = String::from_utf8_lossy(&bytes).to_string();
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let out = run_js(s, &code, path, false)?;
return report_js(s, &name, out);
}
let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
(Some(Value::Array(a)), _) => a.clone(),
(_, Some(path)) => {
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let text = String::from_utf8_lossy(&bytes).to_string();
match serde_json::from_str::<Value>(&text) {
Ok(Value::Array(a)) => a,
_ => text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//") && !l.starts_with('#'))
.map(|l| serde_json::from_str(l).map_err(|e| bad("file.runScript", format!("{e}: {l}"))))
.collect::<Result<Vec<Value>>>()?,
}
}
_ => return Err(bad("file.runScript", "missing `path` (or inline `steps`)")),
};
// A command script read from a file is a script: it can't change what scripts may do.
let from_file = p.get("steps").is_none();
let mut results = vec![];
for (i, step) in steps.iter().enumerate() {
let (id, params) = match (step.get("command").or(step.get("id")).and_then(Value::as_str), step.get("method").and_then(Value::as_str)) {
(Some(id), _) => (id.to_string(), step.get("params").cloned().unwrap_or(json!({}))),
(None, Some("engine.execute")) => {
let pp = step.get("params").cloned().unwrap_or(json!({}));
(pp.get("id").and_then(Value::as_str).unwrap_or_default().to_string(), pp.get("params").cloned().unwrap_or(json!({})))
}
_ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))),
};
if id == "file.runScript" || id == "script.run" {
return Err(bad("file.runScript", "scripts can't run scripts"));
}
let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) };
let r = r.map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
results.push(r);
}
Ok(json!({"steps": results.len(), "results": results}))
}
/// `.jsx` / `.js` (and `.jsxbin`, rejected) are JavaScript; `.json` / `.jsonl` / other files are
/// command scripts.
fn is_js_script(path: &str) -> bool {
let l = path.to_ascii_lowercase();
l.ends_with(".jsx") || l.ends_with(".js") || is_jsxbin(path)
}
/// Compiled `.jsxbin` scripts are an undocumented binary encoding, not JavaScript.
const JSXBIN: &str = "binary .jsxbin scripts are not supported: run the .jsx source";
fn is_jsxbin(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".jsxbin")
}
/// A script error becomes a toast and a command error.
fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
if let Some(e) = out.get("error").filter(|e| !e.is_null()) {
let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default();
let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error");
s.events.push(crate::Event::Toast { message: format!("{name}{line}: {msg}"), error: true });
return Err(EngineError::Other(format!("{name}{line}: {msg}")));
}
Ok(out)
}
/// Run a script's code under its file name (installed / sample scripts, ScriptUI panels).
pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result<Value> {
let out = run_js(s, code, name, false)?;
report_js(s, name, out)
}
/// Run JavaScript through the session's scripting engine.
fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Value> {
let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?;
Ok(runner(s, &crate::ScriptRequest { code, name, console }))
}
/// `script.run`: run JavaScript (the After Effects scripting object model: `app.project`, comps,
/// layers, properties…) and report `{ok, result, output, error}`; script errors are reported in
/// `error` (with line and column), not as a command failure.
fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
let code = str_p(p, "code").ok_or_else(|| bad("script.run", "missing `code` (JavaScript)"))?.to_string();
let name = str_p(p, "name").unwrap_or("script").to_string();
// A compiled script (named `.jsxbin`, or its text starts with the format's header) would only
// fail to parse at 1:1.
if is_jsxbin(&name) || code.trim_start_matches('\u{feff}').trim_start().starts_with("@JSXBIN@") {
return Err(bad("script.run", JSXBIN));
}
run_js(s, &code, &name, super::b_p(p, "console").unwrap_or(false))
}
// ---------------------------------------------------------------- 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>,
/// Color ▸ Preserve RGB.
preserve_rgb: Option<bool>,
/// Start Timecode.
start: Option<StartTimecode>,
}
/// Interpret Footage ▸ Start Timecode.
#[derive(Clone, Debug)]
enum StartTimecode {
/// Use Source File Timecode.
Source,
/// Override Start: a frame number.
Frame(i64),
/// Override Start: timecode, read at the footage's frame rate.
Timecode(String),
}
impl StartTimecode {
/// The highest Override Start, in frames.
const MAX: i64 = 1_000_000_000;
/// The frame (at `rate`) to store, `Some(None)` for Use Source; `None` when the timecode
/// doesn't fit at `rate`.
fn frame(&self, rate: FrameRate) -> Option<Option<i64>> {
match self {
StartTimecode::Source => Some(None),
StartTimecode::Frame(n) => Some(Some(*n)),
StartTimecode::Timecode(t) => Self::read(t, rate).map(Some),
}
}
fn read(t: &str, rate: FrameRate) -> Option<i64> {
effectcraft_time::parse_timecode(t, rate, false, 0).ok().filter(|n| (0..=Self::MAX).contains(n))
}
}
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");
// The Interpret Footage form sends numbers as floats (3.0), which `as_u64` rejects.
it.loops = f_p(p, "loop").filter(|x| x.is_finite()).map(|v| v.round().clamp(1.0, 9999.0) 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");
it.preserve_rgb = super::b_p(p, "preserveRgb");
// Start Timecode: Use Source File Timecode (`overrideStart: false`), or Override Start
// (timecode or a frame number).
let start_err = || bad(cmd, "startTimecode: timecode (0:00:41:20) or a frame number, 0 to 1e9");
it.start = match (super::b_p(p, "overrideStart"), p.get("startTimecode")) {
(Some(false), _) => Some(StartTimecode::Source),
(_, None | Some(Value::Null)) => None,
(_, Some(Value::String(t))) => {
// Read at 30 fps to check it; it's read again at the footage's rate.
StartTimecode::read(t, FrameRate::FPS_30).ok_or_else(start_err)?;
Some(StartTimecode::Timecode(t.clone()))
}
(_, Some(v)) => Some(StartTimecode::Frame(
v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?,
)),
};
Ok(it)
}
pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) {
let before = f.frame_rate;
self.apply_settings(f, guessed);
// A sequence's length is its frames.
if f.kind == FootageKind::Sequence && !f.sequence.is_empty() {
f.sync_sequence_duration();
return;
}
// Conforming keeps the frames and changes how long they last (12 frames at 30 fps are
// 0.4 s; conformed to 12 fps, 1 s). Stills have no frame rate of their own.
let (old, new) = (before.as_f64(), f.frame_rate.as_f64());
if matches!(f.kind, FootageKind::Video | FootageKind::Sequence) && old > 0.0 && new > 0.0 && (old - new).abs() > 1e-9 {
let frames = (f.duration.seconds() * old).round() as i64;
f.duration = f.frame_rate.tick_of(frames);
}
}
fn apply_settings(&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;
}
if let Some(v) = self.preserve_rgb {
f.preserve_rgb = v;
}
// After the frame rate: timecode is read at the footage's rate.
if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) {
f.start_timecode = start;
}
}
}
/// 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 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, g) in items.iter().zip(&guesses) {
let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) else { continue };
let old = f.looped_duration();
it.apply(f, *g);
let new = f.looped_duration();
if new == old {
continue;
}
// Layers that ran to the footage's end still do (After Effects re-times them too).
let ids: Vec<ItemId> = proj.comps().map(|(id, _)| *id).collect();
for cid in ids {
for l in proj.comp_mut(cid).into_iter().flat_map(|c| c.layers.iter_mut()) {
if l.source.item() == Some(*i) && (l.stretch - 100.0).abs() < 1e-9 && l.out_point == l.start_time + old {
l.out_point = l.start_time + new;
}
}
}
}
Ok(())
})?;
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, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(),
"frames": f.sequence_frames(),
})),
_ => None,
})
.collect();
Ok(json!({"items": out}))
}
fn remember_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
let item = *items_p(s, p).first().ok_or_else(|| bad("file.rememberInterpretation", "select footage"))?;
if let Some(ItemKind::Footage(f)) = s.project.item(item).map(|x| &x.kind) {
s.state.interpretation = Some(f.clone());
}
Ok(Value::Null)
}
fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> {
let src = s.state.interpretation.clone().ok_or_else(|| bad("file.applyInterpretation", "no interpretation remembered"))?;
let items = items_p(s, p);
s.edit("Apply Interpretation", None, |proj, _| {
for i in &items {
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
f.alpha = src.alpha;
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.preserve_rgb = src.preserve_rgb;
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);
}
f.frame_rate = src.frame_rate;
}
}
}
Ok(())
})?;
Ok(json!({"items": items.len()}))
}
fn replace_footage(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.replaceFootage", "missing `path`"))?.to_string();
let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceFootage", "select footage"))?;
let importer = s.importer.clone().ok_or_else(|| EngineError::Other("media import is not available in this build".into()))?;
// A numbered still brings its image sequence, as on import (`sequence: false` for one frame).
let opts = SequenceOptions::from_params(p);
let src = crate::sequence::source_of(s.services.as_ref(), &path, opts);
let f = crate::sequence::probe(importer.as_ref(), &src, s.sequence_rate()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let name = src.name();
// Replacing a missing file relinks the other missing files that moved with it (as After Effects
// does when you locate one).
let moved = match s.project.item(item).map(|it| &it.kind) {
Some(ItemKind::Footage(previous)) if previous.missing || !s.stored_paths.exists(s.services.as_ref(), &previous.path) => {
super::relink_moved::moved_with(s, item, &previous.path, &path)
}
_ => vec![],
};
let relinked: Vec<u64> = moved.iter().map(|(id, _)| id.0).collect();
for nf in std::iter::once(&f).chain(moved.iter().map(|(_, nf)| nf)) {
s.stored_paths.allow(nf);
}
s.edit("Replace Footage", None, |proj, _| {
let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceFootage", "no such item"))?;
it.name = name;
it.kind = ItemKind::Footage(f);
retarget_layers(proj, item, |_| LayerSource::Footage { item });
for (id, nf) in moved {
if let Some(it) = proj.item_mut(id) {
it.kind = ItemKind::Footage(nf);
}
}
Ok(())
})?;
if relinked.is_empty() {
return Ok(Value::Null);
}
s.toast(format!("Also relinked {} other missing footage item(s)", relinked.len()));
Ok(json!({"relinked": relinked}))
}
/// Point every layer using `item` at a new source kind.
fn retarget_layers(proj: &mut Project, item: ItemId, src: impl Fn(&LayerSource) -> LayerSource) {
let ids: Vec<ItemId> = proj.comps().map(|(id, _)| *id).collect();
for cid in ids {
if let Some(c) = proj.comp_mut(cid) {
for l in &mut c.layers {
if l.source.item() == Some(item) {
l.source = src(&l.source);
}
}
}
}
}
fn replace_with_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceWithPlaceholder", "select footage"))?;
let base = match s.project.item(item).map(|x| &x.kind) {
Some(ItemKind::Footage(f)) => json!({"width": f.width, "height": f.height, "frameRate": f.frame_rate.as_f64(), "duration": f.duration.seconds()}),
_ => json!({}),
};
let mut merged = base;
if let (Some(m), Some(pm)) = (merged.as_object_mut(), p.as_object()) {
for (k, v) in pm {
m.insert(k.clone(), v.clone());
}
}
let f = placeholder_footage(&merged);
s.edit("Replace Footage", None, |proj, _| {
let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceWithPlaceholder", "no such item"))?;
it.kind = ItemKind::Footage(f);
retarget_layers(proj, item, |_| LayerSource::Footage { item });
Ok(())
})?;
Ok(Value::Null)
}
fn replace_with_solid(s: &mut Session, p: &Value) -> Result<Value> {
let item = *items_p(s, p).first().ok_or_else(|| bad("file.replaceWithSolid", "select footage"))?;
let mut so = solid_from(p, s);
if let Some((w, h)) = s.project.item(item).and_then(|x| x.dimensions())
&& p.get("width").is_none()
{
so.width = w.max(1);
so.height = h.max(1);
}
s.edit("Replace Footage", None, |proj, _| {
let it = proj.item_mut(item).ok_or_else(|| bad("file.replaceWithSolid", "no such item"))?;
it.kind = ItemKind::Solid(so);
retarget_layers(proj, item, |_| LayerSource::Solid { item });
Ok(())
})?;
Ok(Value::Null)
}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let items = items_p(s, p);
let n = crate::footage_reload::reload_items(s, &items)?;
Ok(json!({"reloaded": n}))
}
fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
let item = *items_p(s, p).first().ok_or_else(|| bad("file.revealInFinder", "select footage"))?;
let path = match s.project.item(item).map(|x| &x.kind) {
Some(ItemKind::Footage(f)) if !f.path.is_empty() => f.path.clone(),
_ => return Err(bad("file.revealInFinder", "the item is not a file")),
};
let url = super::layer_menu::reveal_url(s, &path).map_err(|e| bad("file.revealInFinder", e))?;
s.events.push(crate::Event::OpenUrl(url.clone()));
Ok(json!({"url": url}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("project.newFolder", "New Folder", ["File", "New"], Some("Cmd+Alt+Shift+N"), "{name?, parent?}", always, new_folder),
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{comp?}", has_comp, close),
cmd!("file.closeProject", "Close Project", ["File"], None, "{}", always, close_project),
cmd!("file.saveCopy", "Save a Copy...", ["File", "Save As"], None, "{path}", always, save_copy),
cmd!("file.importMultiple", "Multiple Files...", ["File", "Import"], Some("Cmd+Alt+I"), "{paths: [string]}", always, |s, p| s
.execute("file.import", p.clone())),
cmd!(
"file.importPlaceholder",
"Placeholder...",
["File", "Import"],
None,
"{name?, width?, height?, frameRate?, duration? (s)}",
always,
import_placeholder
),
cmd!("file.importSolid", "Solid...", ["File", "Import"], None, "{name?, color?, width?, height?}", always, import_solid),
cmd!(
"file.newCompFromSelection",
"New Comp from Selection...",
["File"],
Some("Cmd+Alt+\\"),
"{duration? (s, for stills), single?: bool (one comp for all), dimensionsFrom?: index, sequence?: bool, overlap?: bool, overlapDuration? (s), transition?: off|dissolveFront|crossDissolve, addToRenderQueue?: bool} → {comps}",
has_project_selection,
new_comp_from_selection
),
cmd!("file.collectFiles", "Collect Files...", ["File", "Dependencies"], None, "{folder}", has_items, collect_files),
cmd!("file.consolidateFootage", "Consolidate All Footage", ["File", "Dependencies"], None, "{}", has_items, consolidate),
cmd!("file.removeUnusedFootage", "Remove Unused Footage", ["File", "Dependencies"], None, "{}", has_items, remove_unused),
cmd!(
"file.reduceProject",
"Reduce Project",
["File", "Dependencies"],
None,
"{} (keeps the selected comps and what they use)",
has_comp_selection,
reduce
),
cmd!("file.findMissing", "Find Missing", [], None, "{what: footage|effects|fonts}", has_items, find_missing),
cmd!(
"footage.check",
"Check Footage",
[],
None,
"{items?: [id], wait?} — look for every footage file (in the background unless `wait`) and flag missing items",
has_items,
crate::footage_check::command
),
cmd!(
"footage.reloadChanged",
"Reload Changed Footage",
[],
None,
"{} — reload the footage whose file changed on disk since it was last seen (size or modification time; Settings ▸ Import ▸ Automatically Reload Footage runs this in the background; image sequences only with All Footage); files seen for the first time are only remembered. Returns {files, reloaded}",
has_items,
crate::footage_reload::command
),
cmd!(
"file.runScript",
"Run Script File...",
["File", "Scripts"],
None,
"{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | steps: [{command, params}]}",
always,
run_script
),
cmd!(
"script.run",
"Run Script",
[],
None,
"{code (JavaScript: app, app.project, CompItem, Layer, Property… like After Effects scripting), name?, console? (Script Console context)} → {ok, result, output, error: {message, line, column} | null}",
always,
script_run
),
cmd!(
"file.interpretFootage",
"Main...",
["File", "Interpret Footage"],
Some("Cmd+Alt+G"),
"{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?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
has_footage_selection,
interpret
),
cmd!(
"file.rememberInterpretation",
"Remember Interpretation",
["File", "Interpret Footage"],
None,
"{item?}",
has_footage_selection,
remember_interpretation
),
cmd!(
"file.applyInterpretation",
"Apply Interpretation",
["File", "Interpret Footage"],
Some("Cmd+Alt+V"),
"{items?}",
has_interpretation,
apply_interpretation
),
cmd!(
"file.replaceFootage",
"File...",
["File", "Replace Footage"],
Some("Cmd+H"),
"{path, item?, sequence?: bool (default true: a numbered still brings its image sequence), alphabetical?: bool}",
has_footage_selection,
replace_footage
),
cmd!(
"file.replaceWithPlaceholder",
"Placeholder...",
["File", "Replace Footage"],
None,
"{item?, width?, height?, frameRate?, duration?}",
has_footage_selection,
replace_with_placeholder
),
cmd!(
"file.replaceWithSolid",
"Solid...",
["File", "Replace Footage"],
None,
"{item?, color?, width?, height?}",
has_footage_selection,
replace_with_solid
),
cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?}", has_footage_selection, reload),
cmd!("file.revealInFinder", "Reveal in Finder", ["File"], None, "{item?}", has_footage_selection, reveal_in_finder),
]
}