594 lines
28 KiB
Rust
594 lines
28 KiB
Rust
//! Menu entries of M14.5: Edit ▸ History, File ▸ Import Recent Footage, Replace Footage ▸ With
|
||
//! Layered Comp, Save a Copy As XML, Animation ▸ Recent Animation Presets, Composition ▸ VR
|
||
//! (Create VR Environment, Extract Cubemap), the "Assign Shortcut to …" submenus, Help ▸ Enable
|
||
//! Logging / Reveal Logging File / System Compatibility Report, and `File.execute()` for scripts.
|
||
|
||
use effectcraft_geom::{Mat4, Vec3, vec3};
|
||
use effectcraft_project::{ItemId, ItemKind, LayerSource};
|
||
use serde_json::{Value, json};
|
||
|
||
use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, has_layers, has_project_selection, str_p};
|
||
use crate::{EngineError, Event, Result, Session, cmd, query};
|
||
|
||
/// Run `f` (which may run several undoable commands) as one undo step named `label`; on error
|
||
/// the project is restored.
|
||
pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
|
||
// The steps `f` records go to an empty stack (so the undo limit can't drop the snapshot
|
||
// taken before them), then become one step on the real one.
|
||
let saved = std::mem::take(&mut s.history.undo);
|
||
let before = s.project.clone();
|
||
let r = f(s);
|
||
let changed = !s.history.undo.is_empty();
|
||
s.history.undo = saved;
|
||
s.history.merge_key = None;
|
||
match r {
|
||
Ok(v) => {
|
||
if changed {
|
||
s.history.undo.push((label.to_string(), before));
|
||
let levels = s.prefs.general.undo_levels.max(1) as usize;
|
||
if s.history.undo.len() > levels {
|
||
let extra = s.history.undo.len() - levels;
|
||
s.history.undo.drain(..extra);
|
||
}
|
||
}
|
||
Ok(v)
|
||
}
|
||
Err(e) => {
|
||
if changed {
|
||
s.project = before;
|
||
s.sanitize_state();
|
||
s.bump();
|
||
}
|
||
Err(e)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn id_of(v: &Value, key: &str) -> Result<u64> {
|
||
v.get(key).and_then(Value::as_u64).ok_or_else(|| EngineError::Other(format!("expected `{key}` in {v}")))
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Edit ▸ History
|
||
|
||
/// Edit ▸ History: `{steps: n}` undoes the last n steps (the entry n−1 of the list), `{redo: n}`
|
||
/// redoes n; without either, lists the steps (newest first).
|
||
fn history(s: &mut Session, p: &Value) -> Result<Value> {
|
||
if let Some(n) = p.get("steps").and_then(Value::as_u64) {
|
||
if n as usize > s.history.undo.len() {
|
||
return Err(bad("edit.history", format!("only {} undo steps", s.history.undo.len())));
|
||
}
|
||
for _ in 0..n {
|
||
s.undo();
|
||
}
|
||
} else if let Some(n) = p.get("redo").and_then(Value::as_u64) {
|
||
if n as usize > s.history.redo.len() {
|
||
return Err(bad("edit.history", format!("only {} redo steps", s.history.redo.len())));
|
||
}
|
||
for _ in 0..n {
|
||
s.redo();
|
||
}
|
||
}
|
||
let undo: Vec<&str> = s.history.undo.iter().rev().map(|(l, _)| l.as_str()).collect();
|
||
let redo: Vec<&str> = s.history.redo.iter().rev().map(|(l, _)| l.as_str()).collect();
|
||
Ok(json!({"undo": undo, "redo": redo}))
|
||
}
|
||
|
||
fn has_history(s: &Session) -> std::result::Result<(), String> {
|
||
if s.history.undo.is_empty() && s.history.redo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) }
|
||
}
|
||
|
||
// ---------------------------------------------------------------- recent footage / presets
|
||
|
||
fn import_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) {
|
||
(Some(x), _) => x.to_string(),
|
||
(None, Some(i)) => s.prefs.recent_footage.get(i as usize).cloned().ok_or_else(|| bad("file.importRecent", format!("no recent footage #{i}")))?,
|
||
_ => s.prefs.recent_footage.first().cloned().ok_or_else(|| bad("file.importRecent", "no recent footage"))?,
|
||
};
|
||
super::file::import(s, &json!({"paths": [path]}))
|
||
}
|
||
|
||
fn has_recent_footage(s: &Session) -> std::result::Result<(), String> {
|
||
if s.prefs.recent_footage.is_empty() { Err("no recent footage".into()) } else { Ok(()) }
|
||
}
|
||
|
||
fn clear_recent_footage(s: &mut Session, _: &Value) -> Result<Value> {
|
||
s.prefs.recent_footage.clear();
|
||
s.save_prefs();
|
||
s.prefs_revision += 1;
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
fn apply_recent_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) {
|
||
(Some(x), _) => x.to_string(),
|
||
(None, Some(i)) => s.prefs.recent_presets.get(i as usize).cloned().ok_or_else(|| bad("anim.applyRecentPreset", format!("no recent preset #{i}")))?,
|
||
_ => s.prefs.recent_presets.first().cloned().ok_or_else(|| bad("anim.applyRecentPreset", "no recent animation presets"))?,
|
||
};
|
||
let mut q = json!({"path": path});
|
||
if let Some(l) = p.get("layers") {
|
||
q["layers"] = l.clone();
|
||
}
|
||
s.execute("anim.applyPreset", q)
|
||
}
|
||
|
||
fn has_recent_presets(s: &Session) -> std::result::Result<(), String> {
|
||
has_layers(s)?;
|
||
if s.prefs.recent_presets.is_empty() { Err("no recent animation presets".into()) } else { Ok(()) }
|
||
}
|
||
|
||
fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
|
||
s.prefs.recent_presets.clear();
|
||
s.save_prefs();
|
||
s.prefs_revision += 1;
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
// ---------------------------------------------------------------- File menu
|
||
|
||
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
|
||
let xml = crate::xml_project::to_xml(&s.project.to_json()).map_err(EngineError::Other)?;
|
||
s.services.write_file(path, xml.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||
s.toast(format!("Saved an XML copy to {path}"));
|
||
Ok(json!({"path": path, "bytes": xml.len()}))
|
||
}
|
||
|
||
/// File ▸ Replace Footage ▸ With Layered Comp: a layered (Photoshop) footage item becomes a
|
||
/// composition of its layers; every layer using the footage now uses the comp, and the footage
|
||
/// item goes. One undo step.
|
||
fn replace_with_layered_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let item = p.get("item").and_then(Value::as_u64).map(ItemId).or_else(|| s.state.project_selection.first().copied());
|
||
let item = item.ok_or_else(|| bad("file.replaceWithLayeredComp", "select a layered footage item"))?;
|
||
let path = match s.project.item(item).map(|i| &i.kind) {
|
||
Some(ItemKind::Footage(f)) if f.codec == "PSD" || f.path.to_ascii_lowercase().ends_with(".psd") => f.path.clone(),
|
||
_ => return Err(bad("file.replaceWithLayeredComp", "the item is not layered footage (a Photoshop file)")),
|
||
};
|
||
let bytes = s.services.read_file(&path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||
grouped(s, "Replace Footage", |s| {
|
||
let (comp, _, _) = super::file::import_psd_comp(s, &path, bytes, false)?;
|
||
let comp = ItemId(comp);
|
||
s.edit("Replace Footage", None, |proj, st| {
|
||
let ids: Vec<ItemId> = proj.items.keys().copied().collect();
|
||
for cid in ids {
|
||
if cid == comp {
|
||
continue;
|
||
}
|
||
if let Some(c) = proj.comp_mut(cid) {
|
||
for l in &mut c.layers {
|
||
if matches!(&l.source, LayerSource::Footage { item: i } if *i == item) {
|
||
l.source = LayerSource::Comp { item: comp };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
proj.items.remove(&item);
|
||
st.project_selection = vec![comp];
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"comp": comp.0}))
|
||
})
|
||
}
|
||
|
||
fn has_footage(s: &Session) -> std::result::Result<(), String> {
|
||
has_project_selection(s)?;
|
||
let ok = s.state.project_selection.iter().any(|i| matches!(s.project.item(*i).map(|x| &x.kind), Some(ItemKind::Footage(_))));
|
||
if ok { Ok(()) } else { Err("select a footage item".into()) }
|
||
}
|
||
|
||
/// `File.execute()` from scripts (and agents): open a file with its default application.
|
||
/// Needs Allow Scripts to Write Files and Access Network; with Warn User When Executing Files on,
|
||
/// the frontend asks first (`confirmed: true` skips the question).
|
||
fn execute_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let path = str_p(p, "path").ok_or_else(|| bad("file.executeFile", "missing `path`"))?;
|
||
if !s.prefs.scripting.allow_scripts_write_files {
|
||
return Err(bad(
|
||
"file.executeFile",
|
||
"scripts can't execute files unless Settings ▸ Scripting & Expressions ▸ Allow Scripts to Write Files and Access Network is on",
|
||
));
|
||
}
|
||
if !std::path::Path::new(path).exists() {
|
||
return Err(bad("file.executeFile", format!("no file {path}")));
|
||
}
|
||
if s.prefs.scripting.warn_executing_files && !b_p(p, "confirmed").unwrap_or(false) {
|
||
s.events.push(Event::Frontend { command: "app.confirmExecute".into(), params: json!({"path": path}) });
|
||
return Ok(json!({"needsConfirmation": path}));
|
||
}
|
||
let url = format!("file://{}", path.replace(' ', "%20"));
|
||
s.events.push(Event::OpenUrl(url));
|
||
Ok(json!({"opened": path}))
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Assign Shortcut to …
|
||
|
||
/// The F10 / F11 / F12 (3D views) or Shift+F10 / F11 / F12 (workspaces) slots.
|
||
fn slot_p(p: &Value, cmd: &str, shift: bool) -> Result<String> {
|
||
let raw = str_p(p, "slot").ok_or_else(|| bad(cmd, "missing `slot` (F10, F11 or F12)"))?;
|
||
let k = raw.trim().trim_start_matches("Shift+").to_ascii_uppercase();
|
||
if !matches!(k.as_str(), "F10" | "F11" | "F12") {
|
||
return Err(bad(cmd, "slot: F10, F11 or F12"));
|
||
}
|
||
Ok(if shift { format!("Shift+{k}") } else { k })
|
||
}
|
||
|
||
/// Bind `keys` to the bindable `key`, taking it from whatever had it.
|
||
fn reassign(s: &mut Session, key: &str, slot: &str, cmd: &str) -> Result<Value> {
|
||
let t = s.shortcuts();
|
||
let b = t.find(key).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut")))?;
|
||
let holders: Vec<(crate::shortcuts::Bindable, Vec<String>)> = t
|
||
.bindables
|
||
.iter()
|
||
.filter(|x| x.key != key && t.keys.get(&x.key).is_some_and(|k| k.iter().any(|k| k == slot)))
|
||
.map(|x| (x.clone(), t.keys.get(&x.key).cloned().unwrap_or_default()))
|
||
.collect();
|
||
let mine = t.keys.get(key).cloned().unwrap_or_default();
|
||
let replaced: Vec<String> = holders.iter().map(|(h, _)| h.label.clone()).collect();
|
||
for (h, keys) in &holders {
|
||
let rest: Vec<String> = keys.iter().filter(|k| *k != slot).cloned().collect();
|
||
s.keymaps.set(h, &rest).map_err(|e| bad(cmd, e))?;
|
||
}
|
||
let mut keys = vec![slot.to_string()];
|
||
keys.extend(mine.into_iter().filter(|k| k != slot));
|
||
let created = s.keymaps.set(&b, &keys).map_err(|e| bad(cmd, e))?;
|
||
s.shortcuts_changed();
|
||
if let Some(n) = &created {
|
||
s.toast(format!("The default shortcuts can't be changed: created the preset \"{n}\""));
|
||
}
|
||
Ok(json!({"command": key, "keys": keys, "replaced": replaced, "preset": s.keymaps.active}))
|
||
}
|
||
|
||
/// The command id of a 3D view.
|
||
pub(crate) fn view_command(v: effectcraft_render::three_d::View3D) -> &'static str {
|
||
use effectcraft_render::three_d::View3D::*;
|
||
match v {
|
||
ActiveCamera => "view.3d.activeCamera",
|
||
Default => "view.3d.default",
|
||
Front => "view.3d.front",
|
||
Left => "view.3d.left",
|
||
Top => "view.3d.top",
|
||
Back => "view.3d.back",
|
||
Right => "view.3d.right",
|
||
Bottom => "view.3d.bottom",
|
||
Custom1 => "view.3d.custom1",
|
||
Custom2 => "view.3d.custom2",
|
||
Custom3 => "view.3d.custom3",
|
||
}
|
||
}
|
||
|
||
/// View ▸ Assign Shortcut to "<current 3D view>": F10, F11 or F12 switches to the view.
|
||
fn assign_view_shortcut(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let slot = slot_p(p, "view.assign3dShortcut", false)?;
|
||
let cid = comp_id(s, p)?;
|
||
let cur = s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default();
|
||
reassign(s, view_command(cur), &slot, "view.assign3dShortcut")
|
||
}
|
||
|
||
/// Window ▸ Assign Shortcut to "<workspace>" Workspace: Shift+F10, Shift+F11 or Shift+F12.
|
||
/// The frontend fills in the current workspace.
|
||
fn assign_workspace_shortcut(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let slot = slot_p(p, "window.assignWorkspaceShortcut", true)?;
|
||
let Some(ws) = str_p(p, "workspace") else { return super::frontend(s, "window.assignWorkspaceShortcut", p) };
|
||
let key = crate::shortcuts::binding_key("window.workspace", &json!({"name": ws}));
|
||
reassign(s, &key, &slot, "window.assignWorkspaceShortcut")
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Help
|
||
|
||
fn log_file(s: &Session) -> std::path::PathBuf {
|
||
crate::logging::log_path(s.config.as_ref().and_then(|c| c.dir()).as_deref())
|
||
}
|
||
|
||
fn enable_logging(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let on = b_p(p, "on").unwrap_or(!crate::logging::is_enabled());
|
||
let file = log_file(s);
|
||
crate::logging::set_enabled(on, file.clone()).map_err(|e| EngineError::Other(format!("cannot write {}: {e}", file.display())))?;
|
||
s.prefs_revision += 1;
|
||
s.toast(if on { format!("Logging to {}", file.display()) } else { "Logging off".into() });
|
||
Ok(json!({"enabled": on, "path": file.to_string_lossy()}))
|
||
}
|
||
|
||
fn reveal_log(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let file = crate::logging::current_file().unwrap_or_else(|| log_file(s));
|
||
let path = file.to_string_lossy().to_string();
|
||
let url = super::layer_menu::folder_url(&path);
|
||
s.events.push(Event::OpenUrl(url.clone()));
|
||
Ok(json!({"path": path, "url": url}))
|
||
}
|
||
|
||
/// Media formats File ▸ Import reads (with the desktop host's decoders).
|
||
pub const IMPORT_FORMATS: &[&str] = &[
|
||
"MP4/MOV (H.264, HEVC, ProRes, AV1)",
|
||
"WebM/MKV (VP8, VP9, AV1)",
|
||
"PNG",
|
||
"JPEG",
|
||
"GIF",
|
||
"WebP",
|
||
"TIFF",
|
||
"BMP",
|
||
"OpenEXR",
|
||
"Photoshop (PSD)",
|
||
"SVG",
|
||
"WAV",
|
||
"AIFF",
|
||
"MP3",
|
||
"FLAC",
|
||
"AAC",
|
||
"Opus",
|
||
"glTF/GLB",
|
||
"OBJ",
|
||
"Lottie JSON",
|
||
];
|
||
|
||
/// Help ▸ System Compatibility Report: OS, CPU, memory, GPU adapter, import/export formats.
|
||
pub fn system_report(s: &Session) -> Value {
|
||
let mem = crate::sysinfo::memory();
|
||
let gb = |b: u64| format!("{:.1} GB", b as f64 / (1u64 << 30) as f64);
|
||
let export: Vec<&str> = s.exporter.as_ref().map(|e| e.formats().into_iter().map(|f| f.label()).collect()).unwrap_or_default();
|
||
let gpu = s.accel.as_ref().map(|a| a.name());
|
||
let budgets = s.prefs.cache_budgets(mem.or(s.sys_memory));
|
||
let mut issues = vec![];
|
||
if gpu.is_none() {
|
||
issues.push("No GPU compositor: rendering uses the CPU (all features still work).".to_string());
|
||
}
|
||
if mem.is_some_and(|m| m.total < 8 << 30) {
|
||
issues.push("Less than 8 GB of memory: use lower preview resolutions for long or large comps.".to_string());
|
||
}
|
||
if s.exporter.is_none() {
|
||
issues.push("Export is not available in this build.".to_string());
|
||
}
|
||
json!({
|
||
"app": format!("EffectCraft {}", env!("CARGO_PKG_VERSION")),
|
||
"os": crate::sysinfo::os_version(),
|
||
"arch": std::env::consts::ARCH,
|
||
"cpu": crate::sysinfo::cpu_name(),
|
||
"cores": crate::sysinfo::cpu_cores(),
|
||
"memory": mem.map(|m| json!({"total": gb(m.total), "available": gb(m.available)})),
|
||
"gpu": gpu,
|
||
"caches": {"layer": gb(budgets.layer as u64), "footage": gb(budgets.media as u64), "preview": gb(budgets.preview as u64)},
|
||
"import": IMPORT_FORMATS,
|
||
"export": export,
|
||
"logging": crate::logging::is_enabled(),
|
||
"recentWarnings": crate::logging::recent().into_iter().rev().take(20).collect::<Vec<_>>(),
|
||
"issues": issues,
|
||
})
|
||
}
|
||
|
||
/// The report as text (the dialog and `--` CLI output).
|
||
pub fn report_text(r: &Value) -> String {
|
||
let st = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or("unknown").to_string();
|
||
let list = |k: &str| r.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")).unwrap_or_default();
|
||
let mut t = format!(
|
||
"{}\nOperating system: {} ({})\nCPU: {} ({} logical cores)\n",
|
||
st("app"),
|
||
st("os"),
|
||
st("arch"),
|
||
st("cpu"),
|
||
r.get("cores").and_then(Value::as_u64).unwrap_or(1)
|
||
);
|
||
match r.get("memory").filter(|m| !m.is_null()) {
|
||
Some(m) => t.push_str(&format!("Memory: {} ({} available)\n", m["total"].as_str().unwrap_or("?"), m["available"].as_str().unwrap_or("?"))),
|
||
None => t.push_str("Memory: unknown\n"),
|
||
}
|
||
t.push_str(&format!("GPU: {}\n", r.get("gpu").and_then(Value::as_str).unwrap_or("none (CPU compositing)")));
|
||
let c = &r["caches"];
|
||
t.push_str(&format!(
|
||
"Cache budgets: layer {}, footage {}, preview {}\n",
|
||
c["layer"].as_str().unwrap_or("?"),
|
||
c["footage"].as_str().unwrap_or("?"),
|
||
c["preview"].as_str().unwrap_or("?")
|
||
));
|
||
t.push_str(&format!("Import: {}\nExport: {}\n", list("import"), list("export")));
|
||
let issues = list("issues");
|
||
t.push_str(&if issues.is_empty() { "No compatibility issues found.".to_string() } else { format!("Issues: {issues}") });
|
||
t
|
||
}
|
||
|
||
fn report(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let r = system_report(s);
|
||
if !b_p(p, "quiet").unwrap_or(false) {
|
||
s.events.push(Event::Frontend { command: "app.showReport".into(), params: json!({"title": "System Compatibility Report", "text": report_text(&r)}) });
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Composition ▸ VR
|
||
|
||
/// Orientation (degrees, AE order) of a camera whose +X, +Y (down) and +Z axes point along the
|
||
/// given world directions: searched over quarter turns, which is all the cube faces need.
|
||
fn orientation_for_axes(x: Vec3, y: Vec3, z: Vec3) -> [f64; 3] {
|
||
let mut best = ([0.0; 3], f64::MAX);
|
||
for a in 0..4 {
|
||
for b in 0..4 {
|
||
for c in 0..4 {
|
||
let o = vec3(a as f64 * 90.0, b as f64 * 90.0, c as f64 * 90.0);
|
||
let m = Mat4::orientation(o);
|
||
let err = (m.apply_vec(vec3(1.0, 0.0, 0.0)) - x).length()
|
||
+ (m.apply_vec(vec3(0.0, 1.0, 0.0)) - y).length()
|
||
+ (m.apply_vec(vec3(0.0, 0.0, 1.0)) - z).length();
|
||
if err < best.1 {
|
||
best = ([o.x, o.y, o.z], err);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
best.0
|
||
}
|
||
|
||
/// Cube faces in the VR Converter's order (+X, −X, +Y, −Y, +Z, −Z): name, forward, right and up
|
||
/// in the VR convention (X right, Y up, Z front).
|
||
const FACES: [(&str, [f64; 3], [f64; 3], [f64; 3]); 6] = [
|
||
("Right", [1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
|
||
("Left", [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
|
||
("Top", [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]),
|
||
("Bottom", [0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
|
||
("Front", [0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
|
||
("Back", [0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
|
||
];
|
||
/// (column, row) of each face in the 3:2 cube map.
|
||
const CELLS_3X2: [(u32, u32); 6] = [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)];
|
||
|
||
/// VR world direction → After Effects world (Y down).
|
||
fn ae(v: [f64; 3]) -> Vec3 {
|
||
vec3(v[0], -v[1], v[2])
|
||
}
|
||
|
||
/// Composition ▸ VR ▸ Create VR Environment: renders the active comp's 3D scene in every
|
||
/// direction. Six face comps (a 90° camera each at `position`, default the comp centre, with a
|
||
/// copy of the scene's layers), a 3:2 cube map of them, and an equirectangular output comp
|
||
/// (VR Converter, cube map → equirectangular). One undo step.
|
||
fn create_vr_environment(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cid = comp_id(s, p)?;
|
||
let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||
let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||
let size = p.get("size").and_then(Value::as_u64).unwrap_or(1024).clamp(64, 8192);
|
||
let center = match p.get("position").and_then(Value::as_array) {
|
||
Some(a) => [0, 1, 2].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0)),
|
||
None => [src.width as f64 / 2.0, src.height as f64 / 2.0, 0.0],
|
||
};
|
||
let fps = src.frame_rate.as_f64();
|
||
let dur = src.duration.seconds();
|
||
let r = grouped(s, "Create VR Environment", |s| {
|
||
let mut faces = vec![];
|
||
for (fname, fw, r, u) in FACES {
|
||
let fc = id_of(
|
||
&s.execute(
|
||
"comp.new",
|
||
json!({"name": format!("{name} VR {fname}"), "width": size, "height": size, "frameRate": fps, "duration": dur, "open": false}),
|
||
)?,
|
||
"comp",
|
||
)?;
|
||
let o = orientation_for_axes(ae(r), ae([-u[0], -u[1], -u[2]]), ae(fw));
|
||
let cam =
|
||
id_of(&s.execute("layer.newCamera", json!({"comp": fc, "name": format!("{fname} Camera"), "angleOfView": 90.0, "type": "oneNode"}))?, "layer")?;
|
||
s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/position", "value": center}))?;
|
||
s.execute("prop.set", json!({"comp": fc, "layer": cam, "path": "transform/orientation", "value": o}))?;
|
||
// The scene's layers (not its cameras), in the scene's coordinates, under the face
|
||
// camera.
|
||
let layers: Vec<effectcraft_project::Layer> = src.layers.iter().filter(|l| !l.is_camera()).cloned().collect();
|
||
s.edit("Create VR Environment", None, |proj, _| {
|
||
let mut next = proj.next_id;
|
||
let mut map = std::collections::BTreeMap::new();
|
||
let mut copies = vec![];
|
||
for l in &layers {
|
||
let mut c = l.clone();
|
||
super::edit::reid(&mut c, &mut next);
|
||
map.insert(l.id, c.id);
|
||
copies.push(c);
|
||
}
|
||
for c in &mut copies {
|
||
c.parent = c.parent.and_then(|p| map.get(&p).copied());
|
||
if let Some(m) = &mut c.track_matte {
|
||
m.layer = map.get(&m.layer).copied().unwrap_or(m.layer);
|
||
}
|
||
}
|
||
proj.next_id = next;
|
||
let comp = proj.comp_mut(ItemId(fc)).ok_or(EngineError::NoComp)?;
|
||
comp.layers.extend(copies);
|
||
Ok(())
|
||
})?;
|
||
faces.push(fc);
|
||
}
|
||
let cube = id_of(
|
||
&s.execute(
|
||
"comp.new",
|
||
json!({"name": format!("{name} VR Cube Map"), "width": size * 3, "height": size * 2, "frameRate": fps, "duration": dur, "open": false}),
|
||
)?,
|
||
"comp",
|
||
)?;
|
||
for (i, fc) in faces.iter().enumerate() {
|
||
let l = id_of(&s.execute("layer.addItem", json!({"comp": cube, "item": fc, "time": 0.0}))?, "layer")?;
|
||
let (c, rr) = CELLS_3X2[i];
|
||
let pos = [(c as f64 + 0.5) * size as f64, (rr as f64 + 0.5) * size as f64];
|
||
s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "transform/position", "value": pos}))?;
|
||
}
|
||
let out = id_of(
|
||
&s.execute(
|
||
"comp.new",
|
||
json!({"name": format!("{name} VR Output"), "width": size * 2, "height": size, "frameRate": fps, "duration": dur, "open": false}),
|
||
)?,
|
||
"comp",
|
||
)?;
|
||
let l = id_of(&s.execute("layer.addItem", json!({"comp": out, "item": cube, "time": 0.0}))?, "layer")?;
|
||
s.execute("prop.set", json!({"comp": out, "layer": l, "path": "transform/scale", "value": [200.0 / 3.0, 50.0, 100.0]}))?;
|
||
s.execute("effect.apply", json!({"comp": out, "layers": [l], "effect": "ec.vr.converter"}))?;
|
||
s.execute("prop.set", json!({"comp": out, "layer": l, "path": "effects/#1/sourceProjection", "value": 2}))?;
|
||
s.execute("prop.set", json!({"comp": out, "layer": l, "path": "effects/#1/targetProjection", "value": 0}))?;
|
||
Ok(json!({"faces": faces, "cubeMap": cube, "output": out}))
|
||
})?;
|
||
s.open_comp(ItemId(r["output"].as_u64().unwrap_or(0)));
|
||
Ok(r)
|
||
}
|
||
|
||
/// Composition ▸ VR ▸ Extract Cubemap: a 3:2 cube-map comp of an equirectangular comp (VR
|
||
/// Converter, equirectangular → cube map), for editing the faces. One undo step.
|
||
fn extract_cubemap(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cid = comp_id(s, p)?;
|
||
let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||
let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||
let face = p.get("faceSize").and_then(Value::as_u64).unwrap_or((src.width / 4).max(16) as u64).clamp(16, 8192);
|
||
let (w, h) = (face * 3, face * 2);
|
||
let r = grouped(s, "Extract Cubemap", |s| {
|
||
let cube = id_of(
|
||
&s.execute(
|
||
"comp.new",
|
||
json!({"name": format!("{name} Cubemap"), "width": w, "height": h, "frameRate": src.frame_rate.as_f64(), "duration": src.duration.seconds(), "open": false}),
|
||
)?,
|
||
"comp",
|
||
)?;
|
||
let l = id_of(&s.execute("layer.addItem", json!({"comp": cube, "item": cid.0, "time": 0.0}))?, "layer")?;
|
||
let scale = [w as f64 / src.width.max(1) as f64 * 100.0, h as f64 / src.height.max(1) as f64 * 100.0, 100.0];
|
||
s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "transform/scale", "value": scale}))?;
|
||
s.execute("effect.apply", json!({"comp": cube, "layers": [l], "effect": "ec.vr.converter"}))?;
|
||
s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "effects/#1/sourceProjection", "value": 0}))?;
|
||
s.execute("prop.set", json!({"comp": cube, "layer": l, "path": "effects/#1/targetProjection", "value": 2}))?;
|
||
Ok(json!({"cubeMap": cube, "layer": l, "faceSize": face}))
|
||
})?;
|
||
s.open_comp(ItemId(r["cubeMap"].as_u64().unwrap_or(0)));
|
||
Ok(r)
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!("edit.history", "History", [], None, "{steps?: n (undo n steps), redo?: n}", has_history, history),
|
||
cmd!("file.importRecent", "Import Recent Footage", [], None, "{index? | path?}", has_recent_footage, import_recent),
|
||
cmd!("file.clearRecentFootage", "Clear Recent Footage", ["File", "Import Recent Footage"], None, "{}", has_recent_footage, clear_recent_footage),
|
||
cmd!("anim.applyRecentPreset", "Recent Animation Presets", [], None, "{index? | path?, layers?}", has_recent_presets, apply_recent_preset),
|
||
cmd!(
|
||
"anim.clearRecentPresets",
|
||
"Clear Recent Presets",
|
||
["Animation", "Recent Animation Presets"],
|
||
None,
|
||
"{}",
|
||
|s| if s.prefs.recent_presets.is_empty() { Err("no recent animation presets".into()) } else { Ok(()) },
|
||
clear_recent_presets
|
||
),
|
||
cmd!("file.saveCopyAsXml", "Save a Copy As XML...", ["File", "Save As"], None, "{path: .ecprojx}", always, save_copy_xml),
|
||
cmd!("file.replaceWithLayeredComp", "With Layered Comp", ["File", "Replace Footage"], None, "{item?}", has_footage, replace_with_layered_comp),
|
||
cmd!("file.executeFile", "Execute File", [], None, "{path, confirmed?}", always, execute_file),
|
||
cmd!("view.assign3dShortcut", "Assign Shortcut to 3D View", [], None, "{slot: F10|F11|F12, comp?}", has_comp, assign_view_shortcut),
|
||
cmd!(
|
||
"window.assignWorkspaceShortcut",
|
||
"Assign Shortcut to Workspace",
|
||
[],
|
||
None,
|
||
"{slot: Shift+F10|Shift+F11|Shift+F12, workspace?}",
|
||
always,
|
||
assign_workspace_shortcut
|
||
),
|
||
cmd!("help.enableLogging", "Enable Logging", ["Help"], None, "{on?}", always, enable_logging),
|
||
cmd!("help.revealLogFile", "Reveal Logging File", ["Help"], None, "{}", always, reveal_log),
|
||
cmd!("help.systemReport", "System Compatibility Report...", ["Help"], None, "{quiet?}", always, report),
|
||
cmd!(
|
||
"comp.vr.createEnvironment",
|
||
"Create VR Environment...",
|
||
["Composition", "VR"],
|
||
None,
|
||
"{comp?, size?: face pixels (1024), position?: [x,y,z]}",
|
||
has_comp,
|
||
create_vr_environment
|
||
),
|
||
cmd!("comp.vr.extractCubemap", "Extract Cubemap...", ["Composition", "VR"], None, "{comp?, faceSize?}", has_comp, extract_cubemap),
|
||
query!("help.systemInfo", "System Information", "{}", |s, _| Ok(system_report(s))),
|
||
]
|
||
}
|