diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs index eea7ffaa..06ab9785 100644 --- a/apps/effectcraft/src/audio_out.rs +++ b/apps/effectcraft/src/audio_out.rs @@ -47,7 +47,15 @@ fn device(name: &str) -> Option { pub fn open(out: &AudioOutput) -> Option> { let dev = device(&out.device)?; let cfg = dev.default_output_config().ok()?; - Some(Box::new(CpalOut { out: out.clone(), rate: cfg.sample_rate().0, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None })) + // Settings ▸ Audio ▸ Preview Sample Rate: open the device at that rate when it supports it + // (otherwise the preview is mixed at that rate and resampled to the device's). + let supports = |r: u32| { + dev.supported_output_configs() + .map(|mut c| c.any(|c| c.channels() == cfg.channels() && c.min_sample_rate().0 <= r && r <= c.max_sample_rate().0)) + .unwrap_or(false) + }; + let rate = if out.rate > 0 && supports(out.rate) { out.rate } else { cfg.sample_rate().0 }; + Some(Box::new(CpalOut { out: out.clone(), rate, latency: Arc::new(AtomicU64::new(0)), stop: None, thread: None })) } fn run>( diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index b6426f5f..157ffba2 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -24,6 +24,8 @@ fn app_icon() -> egui::IconData { } fn main() -> eframe::Result { + // Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report. + effectcraft_engine::logging::install(); let mut control_port: Option = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); let mut files = Vec::new(); let mut demo = true; @@ -94,8 +96,9 @@ fn main() -> eframe::Result { app.hooks.pick_save = Some(Box::new(|name: &str| { rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) })); - app.hooks.pick_open_project = - Some(Box::new(|| rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).pick_file().map(|p| p.to_string_lossy().to_string()))); + app.hooks.pick_open_project = Some(Box::new(|| { + rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string()) + })); app.hooks.audio_device = Some(Box::new(audio_out::open)); app.hooks.audio_devices = Some(Box::new(audio_out::devices)); app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); diff --git a/crates/engine/src/commands/animation.rs b/crates/engine/src/commands/animation.rs index a6390504..aa74c3f2 100644 --- a/crates/engine/src/commands/animation.rs +++ b/crates/engine/src/commands/animation.rs @@ -103,6 +103,12 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad("anim.applyPreset", "select a layer")); } + // Animation ▸ Recent Animation Presets. + if let Some(path) = str_p(p, "path") { + s.prefs.push_recent_preset(path); + s.save_prefs(); + s.prefs_revision += 1; + } let effects: Vec = doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default(); let props: Vec<(String, Property)> = doc["properties"] diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs new file mode 100644 index 00000000..6431d5e2 --- /dev/null +++ b/crates/engine/src/commands/app_more.rs @@ -0,0 +1,593 @@ +//! 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(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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 { + 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 { + 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 { + 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)> = 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 = holders.iter().map(|(h, _)| h.label.clone()).collect(); + for (h, keys) in &holders { + let rest: Vec = 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 "": F10, F11 or F12 switches to the view. +fn assign_view_shortcut(s: &mut Session, p: &Value) -> Result { + 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: Shift+F10, Shift+F11 or Shift+F12. +/// The frontend fills in the current workspace. +fn assign_workspace_shortcut(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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::>(), + "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::>().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 { + 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 { + 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 = 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 { + 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 { + 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))), + ] +} diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 03cd8087..44f49466 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -384,6 +384,8 @@ fn paste(s: &mut Session, p: &Value) -> Result { fn split(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let t = s.time(); + // Settings ▸ General ▸ Create Split Layers Above Original Layer. + let above = s.prefs.general.create_split_layers_above; let new = s.edit("Split Layer", None, |proj, st| { let mut next = proj.next_id; let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; @@ -398,7 +400,7 @@ fn split(s: &mut Session, p: &Value) -> Result { b.in_point = t; comp.layers[i].out_point = t; created.push(b.id); - comp.layers.insert(i, b); + comp.layers.insert(if above { i } else { i + 1 }, b); } proj.next_id = next; st.selected_layers = created.clone(); @@ -408,6 +410,11 @@ fn split(s: &mut Session, p: &Value) -> Result { } fn label(s: &mut Session, p: &Value) -> Result { + // Keyframe colour labels: selected keyframes take the label (unless layers are named). + let explicit_layers = p.get("layers").is_some() || p.get("layer").is_some(); + if p.get("keys").is_some() || (!s.state.selected_keys.is_empty() && !explicit_layers && str_p(p, "target") != Some("layers")) { + return super::keys_more::label_keys(s, p); + } let name = str_p(p, "label").unwrap_or("Red"); let lab = s.prefs.label_from_name(name).ok_or_else(|| super::bad("edit.label", format!("unknown label `{name}`")))?; let (cid, ids) = layers_p(s, p)?; @@ -624,7 +631,7 @@ pub fn specs() -> Vec { cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract), cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_comp, select_all), cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always_ok, deselect_all), - cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?}", always_ok, label), + cmd!("edit.label", "Label", ["Edit", "Label"], None, "{label: Red|Yellow|Aqua|…, layers?, keys?, target?: layers}", always_ok, label), 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), diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 5a69a213..fcff953b 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -40,6 +40,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?; + // XML copies (File ▸ Save a Copy As XML…) open like the JSON project. + let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text }; let proj = Project::from_json(&text)?; s.replace_project(proj, Some(path.to_string())); s.note_project_path(path); @@ -48,6 +50,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { fn save_to(s: &mut Session, path: &str) -> Result { let json = s.project.to_json(); + // A project opened from an XML copy saves as XML again. + let json = if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other)? } else { json }; s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.path = Some(path.to_string()); s.saved_revision = s.revision; @@ -78,14 +82,20 @@ fn revert(s: &mut Session, _: &Value) -> Result { open(s, &json!({"path": path})) } -fn import(s: &mut Session, p: &Value) -> Result { +pub(crate) fn import(s: &mut Session, p: &Value) -> Result { let paths: Vec = match p.get("paths").or(p.get("path")) { Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(), Some(Value::String(x)) => vec![x.clone()], _ => return Err(bad("file.import", "missing `paths`")), }; // Photoshop documents as compositions (Import As: Composition / – Retain Layer Sizes). - let import_as = str_p(p, "importAs").unwrap_or("footage"); + // Files dragged in follow Settings ▸ Import ▸ Default Drag Import As. + let drag = p.get("drag").and_then(Value::as_bool).unwrap_or(false); + let import_as = str_p(p, "importAs").unwrap_or(if drag { s.prefs.drag_import_as() } else { "footage" }); + for path in &paths { + s.prefs.push_recent_footage(path); + } + s.save_prefs(); let retain = match import_as { "footage" => None, "composition" | "comp" => Some(false), @@ -126,6 +136,8 @@ fn import(s: &mut Session, p: &Value) -> Result { let mut probed = vec![]; let psd_layer = p.get("layer").cloned(); let seq_rate = effectcraft_time::FrameRate::from_f64(s.prefs.import.sequence_fps); + let mut ask_alpha = vec![]; + let mut warnings = vec![]; 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) { @@ -141,6 +153,20 @@ fn import(s: &mut Session, p: &Value) -> Result { }; match importer.probe(path) { Ok(mut f) => { + // Settings ▸ Import ▸ Report Missing Frames. + if f.kind == FootageKind::Sequence + && s.prefs.import.report_missing_frames + && let Some(gaps) = missing_frames(&f.sequence) + { + warnings.push(format!("{path}: {gaps}")); + } + // Settings ▸ Import ▸ Interpret Unlabeled Alpha As. + if f.alpha != effectcraft_project::AlphaMode::Ignore && !alpha_is_labeled(path, &f.codec) { + match s.prefs.unlabeled_alpha(f.kind == FootageKind::Video) { + Some(a) => f.alpha = a, + None => ask_alpha.push(paths.iter().position(|x| x == path).unwrap_or(0)), + } + } // Settings ▸ Import ▸ Sequence Footage frames per second. if f.kind == FootageKind::Sequence { let r = seq_rate; @@ -165,6 +191,7 @@ fn import(s: &mut Session, p: &Value) -> Result { Err(e) => errors.push(format!("{path}: {e}")), } } + let mut asked: Vec = vec![]; 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()); @@ -178,8 +205,12 @@ fn import(s: &mut Session, p: &Value) -> Result { FootageKind::Video | FootageKind::Model => Label::Aqua, FootageKind::Data => Label::Sandstone, }; + let ask = ask_alpha.contains(&paths.iter().position(|x| *x == path).unwrap_or(usize::MAX)); let id = proj.add_item(&name, label, None, ItemKind::Footage(f)); ids.push(id.0); + if ask { + asked.push(id.0); + } st.project_selection = vec![id]; } Ok(()) @@ -187,7 +218,44 @@ fn import(s: &mut Session, p: &Value) -> Result { if let Some(c) = out_comps.first() { s.open_comp(effectcraft_project::ItemId(*c)); } - Ok(json!({"items": ids, "comps": out_comps, "errors": errors})) + for w in &warnings { + s.events.push(crate::Event::Toast { message: w.clone(), error: true }); + } + // Ask User: the frontend opens Interpret Footage for footage with unlabeled alpha. + if let Some(first) = asked.first() { + s.events.push(crate::Event::Frontend { command: "file.interpretFootage".into(), params: json!({"item": first, "reason": "unlabeledAlpha"}) }); + } + Ok(json!({"items": ids, "comps": out_comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked})) +} + +/// Whether a file format states how its alpha is stored (PNG, GIF, WebP and PSD are straight by +/// definition, OpenEXR premultiplied); TGA, TIFF and movies with alpha don't. +fn alpha_is_labeled(path: &str, codec: &str) -> bool { + let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + matches!(ext.as_str(), "png" | "gif" | "webp" | "psd" | "psb" | "exr" | "svg" | "jpg" | "jpeg" | "ai" | "pdf" | "eps") || codec.eq_ignore_ascii_case("PSD") +} + +/// Gaps in an image sequence's frame numbers ("missing frames 4–6, 9"), from the digit run at the +/// end of each file name. `None` when the numbering is continuous (or there is none). +pub(crate) fn missing_frames(files: &[String]) -> Option { + let num = |f: &String| -> Option { + let stem = std::path::Path::new(f).file_stem()?.to_string_lossy().to_string(); + let digits: String = stem.chars().rev().take_while(char::is_ascii_digit).collect::>().into_iter().rev().collect(); + digits.parse().ok() + }; + let mut ns: Vec = files.iter().filter_map(num).collect(); + ns.sort_unstable(); + ns.dedup(); + let mut gaps = vec![]; + let mut total = 0; + for w in ns.windows(2) { + if w[1] - w[0] > 1 { + let (a, b) = (w[0] + 1, w[1] - 1); + total += b - a + 1; + gaps.push(if a == b { a.to_string() } else { format!("{a}–{b}") }); + } + } + (!gaps.is_empty()).then(|| format!("{total} missing frame{} ({})", if total == 1 { "" } else { "s" }, gaps.join(", "))) } /// A Photoshop layer by index or name (pixel layers only). @@ -201,7 +269,7 @@ fn find_psd_layer(d: &effectcraft_psd::Psd, sel: &Value) -> Option<(usize, Strin } /// 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, retain: bool) -> Result<(u64, Vec, Vec)> { +pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec, retain: bool) -> Result<(u64, Vec, Vec)> { 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; @@ -381,7 +449,7 @@ pub fn specs() -> Vec { "File...", ["File", "Import"], Some("Cmd+I"), - "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer)}", + "{paths: [string], importAs?: footage|composition|compositionLayerSizes (Photoshop files), layer?: name|index (footage of one Photoshop layer), drag?: bool (dropped files: Settings ▸ Import ▸ Default Drag Import As)}", always, import ), diff --git a/crates/engine/src/commands/keys_more.rs b/crates/engine/src/commands/keys_more.rs new file mode 100644 index 00000000..a64584f4 --- /dev/null +++ b/crates/engine/src/commands/keys_more.rs @@ -0,0 +1,251 @@ +//! Keyframe features of M14.5: Animation ▸ Keyframe Assistant ▸ Convert Audio to Keyframes and +//! RPF Camera Import, and Edit ▸ Label on selected keyframes. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value as KV}; +use effectcraft_project::LayerId; +use effectcraft_project::build::Ids; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::app_more::grouped; +use super::{CommandSpec, bad, comp_id, has_comp, layer_mut, str_p}; +use crate::{EngineError, KeyRef, Result, Session, cmd}; + +// ---------------------------------------------------------------- keyframe labels + +/// The label index (0 = None, 1–16) of a label name (built-in or renamed in Settings ▸ Labels). +fn label_index(s: &Session, name: &str) -> Option { + let l = if name.eq_ignore_ascii_case("none") { Label::None } else { s.prefs.label_from_name(name)? }; + Label::ALL.iter().position(|x| *x == l).map(|i| i as u8) +} + +fn keys_p(s: &Session, p: &Value) -> Vec { + match p.get("keys") { + Some(Value::Array(a)) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(), + _ => s.state.selected_keys.clone(), + } +} + +/// Edit ▸ Label on keyframes: colour the selected (or given) keyframes (label names as renamed +/// in Settings ▸ Labels; `keys.setLabel` is the Timeline context menu's command). +pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result { + let name = str_p(p, "label").unwrap_or("Red"); + let idx = label_index(s, name).ok_or_else(|| bad("keys.label", format!("unknown label `{name}`")))?; + let keys = keys_p(s, p); + if keys.is_empty() { + return Err(bad("keys.label", "select keyframes first")); + } + let cid = comp_id(s, p)?; + let n = s.edit("Keyframe Label", None, |proj, _| { + let mut n = 0; + for k in &keys { + let Ok(l) = layer_mut(proj, cid, k.layer) else { continue }; + let Some(pr) = l.props.find_mut(k.prop) else { continue }; + for key in pr.keys.iter_mut().filter(|x| x.time == k.time) { + key.label = idx; + n += 1; + } + } + Ok(n) + })?; + Ok(json!({"keys": n, "label": idx})) +} + +// ---------------------------------------------------------------- Convert Audio to Keyframes + +/// Per-frame amplitude of interleaved stereo: (left, right, both), mean absolute sample × 100. +pub fn amplitudes(buf: &[f32]) -> (f64, f64, f64) { + let n = (buf.len() / 2).max(1) as f64; + let (mut l, mut r) = (0.0f64, 0.0f64); + for c in buf.chunks_exact(2) { + l += c[0].abs() as f64; + r += c[1].abs() as f64; + } + let (l, r) = (l / n * 100.0, r / n * 100.0); + (l, r, (l + r) / 2.0) +} + +/// Animation ▸ Keyframe Assistant ▸ Convert Audio to Keyframes: an "Audio Amplitude" null with +/// Left Channel, Right Channel and Both Channels sliders keyed on every frame of the work area +/// with the amplitude of the comp's audible layers. One undo step. +fn audio_to_keys(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let fr = comp.frame_rate; + let (a, b) = comp.work_area; + let (f0, f1) = (fr.frame_at(fr.snap_nearest(a)), fr.frame_at(fr.snap_nearest(b)).max(fr.frame_at(fr.snap_nearest(a)) + 1)); + const RATE: u32 = 48_000; + let per = ((RATE as f64 / fr.as_f64()).round() as usize).max(1); + let mut rows = vec![]; + for f in f0..f1 { + let t = fr.tick_of(f); + let buf = effectcraft_render::audio::mix_comp(&s.project, s.footage.as_ref(), s.expr.as_deref(), cid, t, per, RATE); + rows.push((t, amplitudes(&buf))); + } + let spec = effectcraft_effects::lookup("ec.control.slider").ok_or_else(|| EngineError::Other("Slider Control is missing".into()))?; + let peak = rows.iter().map(|(_, (_, _, both))| *both).fold(0.0, f64::max); + let r = grouped(s, "Convert Audio to Keyframes", |s| { + let nl = s.execute("layer.newNull", json!({"comp": cid.0, "name": "Audio Amplitude"}))?["layer"].as_u64().unwrap_or(0); + let lid = LayerId(nl); + s.edit("Convert Audio to Keyframes", None, |proj, st| { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, lid)?; + let start = l.start_time; + let fx = l.props.sub_mut("effects").ok_or_else(|| bad("keys.audioToKeyframes", "the null has no effects group"))?; + for (i, name) in ["Left Channel", "Right Channel", "Both Channels"].into_iter().enumerate() { + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), name, [comp.width as f64, comp.height as f64]); + if let Some(pr) = g.get_mut("slider") { + pr.keys = rows + .iter() + .map(|(t, amp)| { + let v = [amp.0, amp.1, amp.2][i]; + Keyframe::new(*t - start, KV::Scalar((v * 100.0).round() / 100.0)) + }) + .collect(); + } + fx.children.push(g.into()); + } + proj.next_id = next; + st.selected_layers = vec![lid]; + Ok(()) + })?; + Ok(json!({"layer": nl, "frames": rows.len(), "peak": peak})) + })?; + Ok(r) +} + +// ---------------------------------------------------------------- RPF Camera Import + +/// One camera sample: time, position, orientation (degrees, X/Y/Z) and zoom (px). +#[derive(Clone, Debug, PartialEq)] +pub struct CamSample { + pub time: Tick, + pub position: [f64; 3], + pub orientation: [f64; 3], + pub zoom: Option, +} + +fn zoom_of(fov: Option, zoom: Option, comp_w: f64) -> Option { + zoom.or_else(|| fov.map(|a| comp_w * 0.5 / (a.clamp(0.1, 179.0).to_radians() * 0.5).tan())) +} + +/// Parse camera data: JSON `{frameRate?, frames: [{frame | time, position, orientation | +/// rotation, zoom? | fov?}]}` or CSV with a header naming `frame`/`time`, `px py pz` +/// (or `x y z`), `rx ry rz` and `zoom`/`fov` columns. +pub fn parse_camera(text: &str, rate: effectcraft_time::FrameRate, comp_w: f64) -> std::result::Result, String> { + let t = text.trim_start(); + if t.starts_with('{') || t.starts_with('[') { + let v: Value = serde_json::from_str(t).map_err(|e| format!("camera JSON: {e}"))?; + let rate = v.get("frameRate").and_then(Value::as_f64).map(effectcraft_time::FrameRate::from_f64).unwrap_or(rate); + let frames = v.get("frames").or(Some(&v)).and_then(Value::as_array).ok_or("camera JSON: expected `frames`")?; + let v3 = |x: Option<&Value>| -> Option<[f64; 3]> { + let a = x?.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(0.0)]) + }; + return frames + .iter() + .enumerate() + .map(|(i, f)| { + let time = match (f.get("time").and_then(Value::as_f64), f.get("frame").and_then(Value::as_i64)) { + (Some(s), _) => Tick::from_seconds_f64(s), + (None, Some(n)) => rate.tick_of(n), + _ => rate.tick_of(i as i64), + }; + Ok(CamSample { + time, + position: v3(f.get("position")).ok_or(format!("camera JSON: frame {i} needs `position` [x,y,z]"))?, + orientation: v3(f.get("orientation").or(f.get("rotation"))).unwrap_or([0.0; 3]), + zoom: zoom_of(f.get("fov").and_then(Value::as_f64), f.get("zoom").and_then(Value::as_f64), comp_w), + }) + }) + .collect(); + } + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')); + let head: Vec = lines.next().ok_or("camera CSV: empty")?.split([',', ';', '\t']).map(|h| h.trim().to_ascii_lowercase()).collect(); + let col = |names: &[&str]| head.iter().position(|h| names.contains(&h.as_str())); + let (cf, ct) = (col(&["frame", "f"]), col(&["time", "seconds", "t"])); + let pos = [col(&["px", "x", "posx", "position x"]), col(&["py", "y", "posy", "position y"]), col(&["pz", "z", "posz", "position z"])]; + let rot = [col(&["rx", "rotx", "orientation x", "tilt"]), col(&["ry", "roty", "orientation y", "pan"]), col(&["rz", "rotz", "orientation z", "roll"])]; + let (cz, cfov) = (col(&["zoom"]), col(&["fov", "angle", "angleofview"])); + if pos.iter().any(Option::is_none) { + return Err("camera CSV: needs px, py, pz (or x, y, z) columns".into()); + } + lines + .enumerate() + .map(|(i, l)| { + let cells: Vec = l.split([',', ';', '\t']).map(|c| c.trim().parse().unwrap_or(f64::NAN)).collect(); + let get = |c: Option| c.and_then(|c| cells.get(c).copied()).filter(|v| v.is_finite()); + let time = match (get(ct), get(cf)) { + (Some(s), _) => Tick::from_seconds_f64(s), + (None, Some(n)) => rate.tick_of(n as i64), + _ => rate.tick_of(i as i64), + }; + Ok(CamSample { + time, + position: pos.map(|c| get(c).unwrap_or(0.0)), + orientation: rot.map(|c| get(c).unwrap_or(0.0)), + zoom: zoom_of(get(cfov), get(cz), comp_w), + }) + }) + .collect() +} + +/// Animation ▸ Keyframe Assistant ▸ RPF Camera Import: a camera layer keyed from camera data. +/// RPF/RLA files carry their camera in an undocumented block, so EffectCraft reads the same data +/// from JSON or CSV exports (see `docs/preferences.md` ▸ RPF Camera Import). One undo step. +fn rpf_camera_import(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("keys.rpfCameraImport", "missing `path` (.json or .csv camera data)"))?; + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".rpf") || lower.ends_with(".rla") { + return Err(bad( + "keys.rpfCameraImport", + "the camera block of RPF/RLA files is not publicly documented: export the camera from your 3D application as JSON or CSV (frame, px, py, pz, rx, ry, rz, zoom|fov)", + )); + } + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; + let samples = parse_camera(&String::from_utf8_lossy(&bytes), comp.frame_rate, comp.width as f64).map_err(|e| bad("keys.rpfCameraImport", e))?; + if samples.is_empty() { + return Err(bad("keys.rpfCameraImport", "no camera samples")); + } + let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "RPF Camera".into()); + grouped(s, "RPF Camera Import", |s| { + let cam = s.execute("layer.newCamera", json!({"comp": cid.0, "name": name, "type": "oneNode"}))?["layer"].as_u64().unwrap_or(0); + let lid = LayerId(cam); + s.edit("RPF Camera Import", None, |proj, _| { + let l = layer_mut(proj, cid, lid)?; + let start = l.start_time; + let keyed = |vals: Vec<(Tick, KV)>| -> Vec { vals.into_iter().map(|(t, v)| Keyframe::new(t - start, v)).collect() }; + if let Some(pr) = l.props.prop_mut("transform/position") { + pr.keys = keyed(samples.iter().map(|c| (c.time, KV::Vec3(c.position))).collect()); + } + if let Some(pr) = l.props.prop_mut("transform/orientation") { + pr.keys = keyed(samples.iter().map(|c| (c.time, KV::Vec3(c.orientation))).collect()); + } + if samples.iter().any(|c| c.zoom.is_some()) + && let Some(pr) = l.props.prop_mut("cameraOptions/zoom") + { + pr.keys = keyed(samples.iter().filter_map(|c| Some((c.time, KV::Scalar(c.zoom?)))).collect()); + } + Ok(()) + })?; + Ok(json!({"layer": cam, "keyframes": samples.len()})) + }) +} + +pub fn specs() -> Vec { + vec![ + cmd!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], None, "{comp?}", has_comp, audio_to_keys), + cmd!( + "keys.rpfCameraImport", + "RPF Camera Import", + ["Animation", "Keyframe Assistant"], + None, + "{path: .json|.csv camera data, comp?}", + has_comp, + rpf_camera_import + ), + ] +} diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 29b4c595..fec069bd 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -826,12 +826,13 @@ fn add_mask(s: &mut Session, p: &Value) -> Result { _ => ShapePath::rect([cx, cy], rw, rh), }; let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add); + let cycle = s.prefs.appearance.cycle_mask_colors; let uid = s.edit("New Mask", None, |proj, _| { let mut next = proj.next_id; let l = layer_mut(proj, cid, lid)?; let masks = l.props.sub_mut("masks").ok_or_else(|| bad("layer.addMask", "this layer can't have masks"))?; let n = masks.children.len(); - let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, build::MASK_COLORS[n % build::MASK_COLORS.len()]); + let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", n + 1), path, mode, crate::prefs::mask_color(cycle, n)); let uid = g.uid; masks.children.push(g.into()); proj.next_id = next; @@ -913,6 +914,11 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result { fn set_text(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.setText")?; + // The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display). + if let Some(f) = str_p(p, "font") { + s.prefs.push_recent_font(f); + s.save_prefs(); + } let t = s.time(); let range = text_range_p(p); // Character attributes with only a caret (no selected text) set the insertion style. diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index b441c366..263d0d58 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -652,33 +652,38 @@ fn reveal_comp(s: &mut Session, p: &Value) -> Result { Ok(json!({"item": cid.0})) } -fn reveal_expression_errors(s: &mut Session, p: &Value) -> Result { - let cid = comp_id(s, p)?; - let t = s.time(); - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let mut errors = vec![]; - for l in &comp.layers { - let mut has = vec![]; - l.props.walk("", &mut |_, pr| { - if pr.has_expression() { - has.push(pr); - } - }); - for pr in has { - let text = pr.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default(); - let err = match &s.expr { - Some(host) => { - let mut ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t); - ctx.expr = Some(host.as_ref()); - host.eval(&ctx, l, pr, &pr.value_at(l.layer_time(t))).err() +impl Session { + /// Expressions of comp `cid` that fail at its current time: `{layer, prop, name, expression, + /// error}` (Reveal Expression Errors, the viewer's expression error banner). + pub fn expression_errors(&self, cid: effectcraft_project::ItemId) -> Vec { + let t = self.time_of(cid); + let Some(comp) = self.project.comp(cid) else { return vec![] }; + let Some(host) = &self.expr else { return vec![] }; + let mut errors = vec![]; + for l in &comp.layers { + let mut has = vec![]; + l.props.walk("", &mut |_, pr| { + if pr.has_expression() { + has.push(pr); + } + }); + for pr in has { + let text = pr.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default(); + let mut ctx = effectcraft_render::EvalCtx::new(&self.project, cid, comp, t); + ctx.expr = Some(host.as_ref()); + if let Err(e) = host.eval(&ctx, l, pr, &pr.value_at(l.layer_time(t))) { + errors.push(json!({"layer": l.id.0, "prop": pr.uid, "name": pr.name, "expression": text, "error": e})); } - None => None, - }; - if let Some(e) = err { - errors.push(json!({"layer": l.id.0, "prop": pr.uid, "name": pr.name, "expression": text, "error": e})); } } + errors } +} + +fn reveal_expression_errors(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + s.project.comp(cid).ok_or(EngineError::NoComp)?; + let errors = s.expression_errors(cid); let props: Vec = errors.iter().map(|e| json!({"layer": e["layer"], "prop": e["prop"]})).collect(); frontend(s, "timeline.revealProps", &json!({"props": props}))?; if errors.is_empty() { diff --git a/crates/engine/src/commands/link.rs b/crates/engine/src/commands/link.rs index 1e070ab5..3892bd06 100644 --- a/crates/engine/src/commands/link.rs +++ b/crates/engine/src/commands/link.rs @@ -101,7 +101,7 @@ fn quote(s: &str) -> String { } /// The AE expression reference to property `uid` of `target` as seen from a property of `from`. -pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid) -> Option { +pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid, compact: bool) -> Option { let chain = target.props.node_chain(uid)?; let mut parts: Vec = vec![]; let mut pending: Option<&str> = None; @@ -125,6 +125,15 @@ pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid) -> } let mut out = if from.id == target.id { String::new() } else { format!("thisComp.layer({}).", quote(&target.name)) }; let _ = comp; + if !compact { + // Match names: language-independent, like After Effects with Compact English off. + let chain = target.props.node_chain(uid)?; + let mut out = if from.id == target.id { "thisLayer".to_string() } else { format!("thisComp.layer({})", quote(&target.name)) }; + for n in chain { + out.push_str(&format!("({})", quote(n.match_id()))); + } + return Some(out); + } for (i, p) in parts.iter().enumerate() { if i > 0 && !p.starts_with('(') { out.push('.'); @@ -152,7 +161,7 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let from = comp.layer(lid).ok_or(EngineError::NoComp)?; let target = comp.layer(tl).ok_or(EngineError::NoComp)?; - let r = reference(comp, from, target, tu).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; + let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?); let (dm, dt) = (dims(from, me), dims(target, them)); let text = if dt == 1 && dm > 1 { diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs index da1e9c33..359de14d 100644 --- a/crates/engine/src/commands/mask.rs +++ b/crates/engine/src/commands/mask.rs @@ -54,9 +54,76 @@ pub(crate) fn path_group<'a>(props: &'a mut PropGroup, key: &Value) -> Option<&' /// Edit the mask path of (layer, mask) at the current time. fn edit_path(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result) -> Result<(Uid, T)> { + edit_path_counted(s, p, label, |sp| f(sp).map(|r| (r, None))) +} + +/// A change of a path's vertex count, repeated on the other keyframes of an animated path when +/// Settings ▸ General ▸ Preserve Constant Vertex and Feather Point Count is on. +#[derive(Clone, Debug)] +pub(crate) enum CountOp { + /// A vertex was inserted at this index. + Insert(usize), + /// These vertices were removed. + Remove(Vec), +} + +/// Apply a vertex-count change to another keyframe's path: an inserted vertex splits the +/// matching segment in half (the shape doesn't change), removed vertices go. +pub(crate) fn apply_count_op(sp: &mut ShapePath, op: &CountOp) { + match op { + CountOp::Insert(at) => { + let n = sp.vertices.len(); + if n == 0 { + return; + } + if *at > 0 && split_segment(sp, at - 1, 0.5).is_some() { + return; + } + let at = (*at).min(n); + let v = sp.vertices[at.min(n - 1)]; + sp.in_tangents.resize(n, [0.0; 2]); + sp.out_tangents.resize(n, [0.0; 2]); + sp.vertices.insert(at, v); + sp.in_tangents.insert(at, [0.0; 2]); + sp.out_tangents.insert(at, [0.0; 2]); + } + CountOp::Remove(idx) => { + let mut idx = idx.clone(); + idx.sort_unstable(); + idx.dedup(); + for &i in idx.iter().rev() { + if i < sp.vertices.len() { + sp.vertices.remove(i); + if i < sp.in_tangents.len() { + sp.in_tangents.remove(i); + } + if i < sp.out_tangents.len() { + sp.out_tangents.remove(i); + } + } + } + if sp.vertices.len() < 3 { + sp.closed = false; + } + } + } +} + +/// Repeat `op` on every keyframe of `pr` other than the one at `lt`. +fn sync_keys(pr: &mut effectcraft_project::Property, lt: effectcraft_time::Tick, op: &CountOp) { + for k in pr.keys.iter_mut().filter(|k| k.time != lt) { + if let KV::Path(sp) = &mut k.value { + apply_count_op(sp, op); + } + } +} + +/// [`edit_path`] whose edit may change the vertex count (`f` says how). +fn edit_path_counted(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<(T, Option)>) -> Result<(Uid, T)> { let (cid, lid) = layer_p(s, p, label)?; let key = p.get("mask").cloned().ok_or_else(|| bad(label, "missing `mask` (uid, index or name)"))?; let t = s.time(); + let preserve = s.prefs.general.preserve_constant_vertex_count; s.edit(label, merge_p(p), |proj, _| { let l = layer_mut(proj, cid, lid)?; let lt = l.layer_time(t); @@ -65,11 +132,14 @@ fn edit_path(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut Sha let roto = super::paths::is_roto(g); let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?; let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) }; - let r = f(&mut sp)?; + let (r, op) = f(&mut sp)?; if roto { super::paths::roto_smooth(&mut sp); } pr.set_value_at(lt, KV::Path(sp)); + if let Some(op) = op.filter(|_| preserve) { + sync_keys(pr, lt, &op); + } Ok((uid, r)) }) } @@ -88,12 +158,13 @@ fn new_mask(s: &mut Session, p: &Value) -> Result { outs.resize(n, [0.0; 2]); let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() }; let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add); + let cycle = s.prefs.appearance.cycle_mask_colors; let uid = s.edit("New Mask", None, |proj, st| { let mut next = proj.next_id; let l = layer_mut(proj, cid, lid)?; let masks = l.props.sub_mut("masks").ok_or_else(|| bad("mask.new", "this layer can't have masks"))?; let k = masks.children.len(); - let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", k + 1), path, mode, build::MASK_COLORS[k % build::MASK_COLORS.len()]); + let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", k + 1), path, mode, crate::prefs::mask_color(cycle, k)); let uid = g.uid; masks.children.push(g.into()); proj.next_id = next; @@ -109,13 +180,13 @@ fn add_vertex(s: &mut Session, p: &Value) -> Result { let tout = pt(p.get("out")).unwrap_or([0.0; 2]); let at = p.get("index").and_then(Value::as_u64).map(|i| i as usize); let (_, lid) = layer_p(s, p, "mask.addVertex")?; - let (uid, i) = edit_path(s, p, "mask.addVertex", |sp| { + let (uid, i) = edit_path_counted(s, p, "mask.addVertex", |sp| { let i = at.unwrap_or(sp.vertices.len()).min(sp.vertices.len()); sp.vertices.insert(i, point); sp.in_tangents.insert(i, tin); sp.out_tangents.insert(i, tout); sp.feather_after_insert(i, None); - Ok(i) + Ok((i, Some(CountOp::Insert(i)))) })?; s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }]; Ok(json!(i)) @@ -197,9 +268,16 @@ fn has_vertices(s: &Session) -> std::result::Result<(), String> { /// Apply `f` to the selected vertices' paths (each mask once). fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapePath, &[usize])) -> Result { + edit_vertices_counted(s, p, label, false, f) +} + +/// [`edit_vertices`]; `removes`: `f` deletes the given vertices (repeated on the other keyframes +/// when Preserve Constant Vertex Count is on). +fn edit_vertices_counted(s: &mut Session, p: &Value, label: &str, removes: bool, f: impl Fn(&mut ShapePath, &[usize])) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let groups = vertex_groups(s, p)?; let t = s.time(); + let preserve = removes && s.prefs.general.preserve_constant_vertex_count; s.edit(label, merge_p(p), |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; for ((l, m), idx) in &groups { @@ -214,6 +292,9 @@ fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapeP super::paths::roto_smooth(&mut sp); } pr.set_value_at(lt, KV::Path(sp)); + if preserve { + sync_keys(pr, lt, &CountOp::Remove(idx.clone())); + } } Ok(()) })?; @@ -233,7 +314,7 @@ fn move_vertices(s: &mut Session, p: &Value) -> Result { } fn delete_vertices(s: &mut Session, p: &Value) -> Result { - let n = edit_vertices(s, p, "Delete Mask Vertices", |sp, idx| { + let n = edit_vertices_counted(s, p, "Delete Mask Vertices", true, |sp, idx| { let mut idx = idx.to_vec(); idx.sort_unstable(); idx.dedup(); @@ -525,7 +606,10 @@ fn insert_vertex(s: &mut Session, p: &Value) -> Result { p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("mask.insertVertex", "missing `segment` (index of the segment's first vertex)"))? as usize; let t = super::f_p(p, "t").unwrap_or(0.5).clamp(0.0, 1.0); let (_, lid) = layer_p(s, p, "mask.insertVertex")?; - let (uid, i) = edit_path(s, p, "Add Vertex", |sp| split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment")))?; + let (uid, i) = edit_path_counted(s, p, "Add Vertex", |sp| { + let i = split_segment(sp, seg, t).ok_or_else(|| bad("mask.insertVertex", "no such segment"))?; + Ok((i, Some(CountOp::Insert(i)))) + })?; s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }]; Ok(json!(i)) } diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 4112cf6c..ed09151f 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -4,6 +4,7 @@ mod anim; pub mod anim_tools; mod animation; +mod app_more; mod camera_cmds; mod comp; mod comp_more; @@ -19,6 +20,7 @@ mod frontend; mod help; mod key_labels; mod key_transform; +mod keys_more; mod layer; mod layer_menu; mod layer_time; @@ -53,6 +55,13 @@ mod view; pub mod viewer_cmds; mod warp_cmds; #[cfg(test)] +pub(crate) use app_more::report_text as report_text_for_tests; +pub(crate) use app_more::view_command; +#[cfg(test)] +pub(crate) use file::missing_frames as missing_frames_for_tests; +#[cfg(test)] +pub(crate) use keys_more::amplitudes as amplitudes_for_tests; +#[cfg(test)] pub(crate) use mask::split_segment as split_segment_for_tests; use std::sync::OnceLock; @@ -155,6 +164,8 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(puppet::specs()); v.extend(liquify::specs()); v.extend(settings::specs()); + v.extend(app_more::specs()); + v.extend(keys_more::specs()); v.extend(essential::specs()); v.extend(expr_tools::specs()); v.extend(proxy::specs()); diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 2f34898c..f0ada926 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -184,7 +184,7 @@ fn set_expr(s: &mut Session, p: &Value) -> Result { enabled = Some(false); } // Alt-click on a stopwatch starts with the property's own reference (`transform.opacity`). - let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid))).unwrap_or_else(|| "value".into()); + let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid, true))).unwrap_or_else(|| "value".into()); let r = with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| { match (&text, enabled) { (Some(t), _) if t.trim().is_empty() => pr.expr = None, diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index 91467ea5..bb2b8ce5 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -499,8 +499,15 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result { let mut item = RenderQueueItem::new(0, cid); item.output = OutputModule::for_format(format); item.output.output = path.clone(); - let job = - crate::ExportJob { project: &s.project, footage: s.footage.as_ref(), expr: s.expr.as_deref(), accel: s.accel.as_deref(), item: &item, path: &path }; + let job = crate::ExportJob { + project: &s.project, + footage: s.footage.as_ref(), + expr: s.expr.as_deref(), + accel: s.accel.as_deref(), + item: &item, + path: &path, + nested_switches: s.prefs.general.switches_affect_nested_comps, + }; let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?; s.toast(format!("Saved preview to {}", r.path)); Ok(serde_json::to_value(&r).unwrap_or_default()) diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 2751fbc3..923b3e90 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -26,8 +26,6 @@ pub fn specs() -> Vec { stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"), // Cameras / lights / materials / 3D views (3D milestone). // Keyframes / text / tracking. - stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"), - stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"), // Workspaces and panels that don't exist yet. stub!("window.unavailablePanel", "Panel", [], "{panel}"), ] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9333329a..057c7d10 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,7 +14,9 @@ pub mod commands; pub mod config; pub mod demo; pub mod links; +pub mod logging; pub mod mask_track; +pub mod media_cache; pub mod menus; pub mod offload; pub mod prefs; @@ -23,10 +25,12 @@ pub mod render_queue; pub mod roto; mod session_settings; pub mod shortcuts; +pub mod sysinfo; pub mod tracking; pub mod vector; pub mod viewer; pub mod warp; +pub mod xml_project; use std::sync::Arc; @@ -320,6 +324,11 @@ pub struct Session { /// The JavaScript scripting engine (set by the host that links `effectcraft-script`): /// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console. pub script: Option, + /// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown + /// (headless sessions don't query it, see [`Session::memory_tick`]). + pub sys_memory: Option, + /// Switches Affect Nested Comps as last applied (a change empties the layer cache). + pub applied_nested_switches: Option, /// Runs renders and analyses off the UI thread where there are no threads (the web app's /// Web Workers, see [`offload`]); `None` = threads (desktop) or inline (wasm32). pub offload: Option>, @@ -379,6 +388,8 @@ impl Default for Session { autosave: autosave::AutoSaveState::default(), snapshot: None, script: None, + sys_memory: None, + applied_nested_switches: None, offload: None, offloaded: vec![], } @@ -548,6 +559,47 @@ impl Session { None => t, }; self.state.times.insert(c, t); + if self.prefs.general.sync_time_related_items { + self.sync_related_times(c, t); + } + } + } + + /// Settings ▸ General ▸ Synchronize Time of All Related Items: moving the current time of + /// `comp` moves the current time of the comps nested in it (through their precomp layers' + /// timing) and of the comps it is nested in, so every related viewer shows the same moment. + pub fn sync_related_times(&mut self, comp: ItemId, t: Tick) { + let mut seen = std::collections::BTreeSet::from([comp]); + let mut todo = vec![(comp, t)]; + let mut found = vec![]; + while let Some((cid, ct)) = todo.pop() { + if cid != comp { + found.push((cid, ct)); + } + let Some(c) = self.project.comp(cid) else { continue }; + // Down: the comps this one nests. + for l in &c.layers { + if let effectcraft_project::LayerSource::Comp { item } = &l.source + && seen.insert(*item) + { + todo.push((*item, l.layer_time(ct))); + } + } + // Up: the comps nesting this one. + for iid in self.project.items.keys() { + if let Some(pc) = self.project.comp(*iid) + && let Some(l) = pc.layers.iter().find(|l| matches!(&l.source, effectcraft_project::LayerSource::Comp { item } if *item == cid)) + && seen.insert(*iid) + { + todo.push((*iid, l.comp_time(ct))); + } + } + } + for (cid, ct) in found { + if let Some(c) = self.project.comp(cid) { + let ct = c.frame_rate.snap_nearest(ct.clamp(Tick::ZERO, (c.duration - c.frame_duration()).max(Tick::ZERO))); + self.state.times.insert(cid, ct); + } } } @@ -589,6 +641,7 @@ impl Session { /// Render a comp frame with the session's footage + expression hosts. pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image { + let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts }; let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts); r.expr = self.expr.as_deref(); r.cache = Some(&self.layer_cache); @@ -704,6 +757,8 @@ mod tests_fidelity; #[cfg(test)] mod tests_lottie; #[cfg(test)] +mod tests_m145; +#[cfg(test)] mod tests_markers; #[cfg(test)] mod tests_mask_warp; @@ -747,6 +802,68 @@ pub fn text_presets() -> Vec<(String, String)> { pub fn text_families() -> Vec { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() } + +/// One row of the Character panel's font menu. +#[derive(Clone, Debug, PartialEq)] +pub struct FontRow { + /// The family to apply (empty for the separator after the recent fonts). + pub family: String, + /// What the menu shows: the English family name, or the font's own-language name when + /// Settings ▸ Type ▸ Show Font Names in English is off. + pub display: String, + pub recent: bool, +} + +/// Outline polylines of "Sample" set in `family` at `size` px (baseline at y = 0, y down), for +/// the font menu's preview (Settings ▸ Type ▸ Show Font Preview). +pub fn font_preview(family: &str, size: f64) -> Vec> { + use kurbo::PathEl; + let st = effectcraft_keyframe::text_doc::CharStyle { font: family.to_string(), size, ..Default::default() }; + let mut out = vec![]; + let mut x = 0.0; + for ch in "Sample".chars() { + let (path, adv) = effectcraft_text::char_glyph_style(&st, ch); + let mut cur: Vec<[f32; 2]> = vec![]; + let pt = |p: kurbo::Point| [(p.x + x) as f32, p.y as f32]; + kurbo::flatten(&path, 0.2, |el| match el { + PathEl::MoveTo(p) => { + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + cur = vec![pt(p)]; + } + PathEl::LineTo(p) => cur.push(pt(p)), + PathEl::ClosePath => { + if let Some(f) = cur.first().copied() { + cur.push(f); + } + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + } + _ => {} + }); + if cur.len() > 1 { + out.push(cur); + } + x += adv; + } + out +} + +/// The Character panel's font menu: the recent fonts (Settings ▸ Type ▸ Number of Recent Fonts +/// to Display), a separator, then every family. +pub fn font_menu(prefs: &prefs::Prefs) -> Vec { + let all = text_families(); + let native = if prefs.type_.font_names_in_english { Default::default() } else { effectcraft_text::fonts::native_families() }; + let row = |f: &String, recent: bool| FontRow { family: f.clone(), display: native.get(f).cloned().unwrap_or_else(|| f.clone()), recent }; + let mut out: Vec = prefs.recent_fonts_shown().iter().filter(|f| all.contains(f)).map(|f| row(f, true)).collect(); + if !out.is_empty() { + out.push(FontRow { family: String::new(), display: "-".into(), recent: false }); + } + out.extend(all.iter().map(|f| row(f, false))); + out +} #[cfg(test)] mod tests_paint; #[cfg(test)] diff --git a/crates/engine/src/logging.rs b/crates/engine/src/logging.rs new file mode 100644 index 00000000..23ef28f0 --- /dev/null +++ b/crates/engine/src/logging.rs @@ -0,0 +1,99 @@ +//! Help ▸ Enable Logging / Reveal Logging File: a `log` logger that, while enabled, appends +//! every record to `Logs/EffectCraft Log.txt` in the settings folder (or the temporary folder), +//! and always keeps the last few hundred warnings and errors in memory for the System +//! Compatibility Report. +//! +//! Frontends call [`install`] once at startup; [`set_enabled`] (the `help.enableLogging` +//! command) turns file logging on and off. + +use std::collections::VecDeque; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; + +static ENABLED: AtomicBool = AtomicBool::new(false); +static STATE: Mutex = Mutex::new(State { file: None, recent: VecDeque::new() }); + +struct State { + file: Option, + recent: VecDeque, +} + +struct Logger; + +const RECENT: usize = 300; + +impl log::Log for Logger { + fn enabled(&self, m: &log::Metadata) -> bool { + m.level() <= log::Level::Warn || (ENABLED.load(Ordering::Relaxed) && m.target().starts_with("effectcraft")) + } + fn log(&self, r: &log::Record) { + if !self.enabled(r.metadata()) { + return; + } + let line = format!("{} {:<5} {}: {}", unix_secs(), r.level(), r.target(), r.args()); + let Ok(mut st) = STATE.lock() else { return }; + if r.level() <= log::Level::Warn { + st.recent.push_back(line.clone()); + if st.recent.len() > RECENT { + st.recent.pop_front(); + } + } + if ENABLED.load(Ordering::Relaxed) + && let Some(f) = &st.file + && let Ok(mut out) = std::fs::OpenOptions::new().create(true).append(true).open(f) + { + let _ = writeln!(out, "{line}"); + } + } + fn flush(&self) {} +} + +fn unix_secs() -> u64 { + web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +/// Install the logger (once; later calls and other installed loggers are left alone). +pub fn install() { + static LOGGER: Logger = Logger; + if log::set_logger(&LOGGER).is_ok() { + log::set_max_level(log::LevelFilter::Info); + } +} + +/// The log file inside `config_dir` (the settings folder), or the temporary folder. +pub fn log_path(config_dir: Option<&Path>) -> PathBuf { + config_dir.map(Path::to_path_buf).unwrap_or_else(std::env::temp_dir).join("Logs").join("EffectCraft Log.txt") +} + +/// Turn file logging on (writing to `file`) or off. +pub fn set_enabled(on: bool, file: PathBuf) -> std::io::Result<()> { + install(); + if on { + if let Some(d) = file.parent() { + std::fs::create_dir_all(d)?; + } + let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&file)?; + writeln!(f, "{} INFO effectcraft: logging enabled ({} {})", unix_secs(), std::env::consts::OS, env!("CARGO_PKG_VERSION"))?; + } + if let Ok(mut st) = STATE.lock() { + st.file = Some(file); + } + ENABLED.store(on, Ordering::Relaxed); + Ok(()) +} + +pub fn is_enabled() -> bool { + ENABLED.load(Ordering::Relaxed) +} + +/// The current log file (set once logging was enabled). +pub fn current_file() -> Option { + STATE.lock().ok().and_then(|s| s.file.clone()) +} + +/// Recent warnings and errors, oldest first. +pub fn recent() -> Vec { + STATE.lock().map(|s| s.recent.iter().cloned().collect()).unwrap_or_default() +} diff --git a/crates/engine/src/media_cache.rs b/crates/engine/src/media_cache.rs new file mode 100644 index 00000000..1a9b932a --- /dev/null +++ b/crates/engine/src/media_cache.rs @@ -0,0 +1,65 @@ +//! The media cache (Settings ▸ Disk ▸ Database and Cache Folder): audio waveform summaries kept +//! on disk so a footage file is summarised once, not in every session. +//! +//! A summary is stored as `Peaks/_.ecpk` (raw little-endian `f32` quadruples +//! `[min L, max L, min R, max R]` per bin), named by a hash of the footage path, its size and +//! modification time, so an edited file is summarised again. + +use std::path::{Path, PathBuf}; + +/// The peak file of footage `path` at `bins` bins per second in the media cache `folder` +/// (`None` when the file can't be inspected). +pub fn peaks_path(folder: &Path, path: &str, bins: u32) -> Option { + use std::hash::{Hash, Hasher}; + let meta = std::fs::metadata(path).ok()?; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (path, meta.len(), meta.modified().ok()).hash(&mut h); + Some(folder.join("Peaks").join(format!("{:016x}_{bins}.ecpk", h.finish()))) +} + +/// Read a stored summary. +pub fn load_peaks(file: &Path) -> Option> { + let b = std::fs::read(file).ok()?; + if b.len() % 16 != 0 { + return None; + } + let f = |c: &[u8]| f32::from_le_bytes([c[0], c[1], c[2], c[3]]); + Some(b.chunks_exact(16).map(|c| [f(&c[0..4]), f(&c[4..8]), f(&c[8..12]), f(&c[12..16])]).collect()) +} + +/// Store a summary (atomically: written beside, then renamed). +pub fn store_peaks(file: &Path, peaks: &[[f32; 4]]) -> std::io::Result<()> { + if let Some(d) = file.parent() { + std::fs::create_dir_all(d)?; + } + let mut b = Vec::with_capacity(peaks.len() * 16); + for p in peaks { + for v in p { + b.extend_from_slice(&v.to_le_bytes()); + } + } + crate::config::atomic_write(file, &b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn peaks_round_trip_and_follow_the_file() { + let d = std::env::temp_dir().join(format!("ec-media-cache-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let src = d.join("a.wav"); + std::fs::write(&src, b"one").unwrap(); + let p = peaks_path(&d, src.to_str().unwrap(), 200).unwrap(); + assert!(p.starts_with(d.join("Peaks"))); + let peaks = vec![[-0.5, 0.5, -0.25, 0.25], [0.0, 0.1, -0.1, 0.0]]; + store_peaks(&p, &peaks).unwrap(); + assert_eq!(load_peaks(&p).unwrap(), peaks); + // A changed file gets another name. + std::fs::write(&src, b"longer").unwrap(); + assert_ne!(peaks_path(&d, src.to_str().unwrap(), 200).unwrap(), p); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 51fa4e02..ca27effa 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -12,6 +12,8 @@ //! Next Blending Mode | layer.setBlendMode {"step":1} | Shift+= //! --- separator //! @effects the effect categories (Effect menu) +//! @dynamic:history entries computed from the session when the menu +//! opens ([`dynamic`]: recent files, undo history…) //! [mac] Quit EffectCraft | app.quit only on macOS ([!mac] = everywhere else) //! ``` //! @@ -35,11 +37,120 @@ use crate::commands::{self, CommandSpec}; pub enum MenuNode { Item(MenuEntry), Separator, - Submenu { label: String, children: Vec }, + Submenu { + label: String, + children: Vec, + }, + /// Entries computed when the menu opens (see [`dynamic`]). + Dynamic { + name: String, + }, +} + +/// Frontend state dynamic menus depend on. +#[derive(Clone, Copy, Debug, Default)] +pub struct DynCtx<'a> { + /// The current workspace (Window ▸ Assign Shortcut to "…" Workspace). + pub workspace: Option<&'a str>, +} + +fn file_name(p: &str) -> String { + std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.to_string()) +} + +fn dyn_entry(label: String, command: &str, params: Value) -> MenuEntry { + MenuEntry { label, command: command.into(), params, shortcut: None } +} + +/// The bindable holding shortcut `keys` in the active preset (its label). +fn holder_of(s: &Session, keys: &str) -> Option { + let t = s.shortcuts(); + t.bindables.iter().find(|b| t.keys.get(&b.key).is_some_and(|k| k.iter().any(|k| k == keys))).map(|b| b.label.clone()) +} + +/// The entries of a `@dynamic:` node, and the disabled placeholder shown when there are +/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest +/// first), `view3dShortcuts`, `workspaceShortcuts` and `openViewers` (open compositions other +/// than the active one). +pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec, Option<&'static str>) { + use serde_json::json; + let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec { + list.iter() + .enumerate() + .map(|(i, p)| { + let n = + if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) }; + dyn_entry(n, cmd, json!({"index": i})) + }) + .collect() + }; + match name { + "recentProjects" => (indexed(&s.prefs.recent_projects, "file.openRecent", false), Some("No Recent Projects")), + "recentFootage" => (indexed(&s.prefs.recent_footage, "file.importRecent", false), Some("No Recent Footage")), + "recentPresets" => (indexed(&s.prefs.recent_presets, "anim.applyRecentPreset", true), Some("No Recent Presets")), + "history" => ( + s.history.undo.iter().rev().enumerate().map(|(i, (l, _))| dyn_entry(format!("Undo {l}"), "edit.history", json!({"steps": i + 1}))).collect(), + Some("No History"), + ), + "view3dShortcuts" => ( + ["F10", "F11", "F12"] + .iter() + .map(|k| { + let label = match holder_of(s, k) { + Some(h) => format!("{k} (Replace “{h}”)"), + None => k.to_string(), + }; + dyn_entry(label, "view.assign3dShortcut", json!({"slot": k})) + }) + .collect(), + None, + ), + "workspaceShortcuts" => ( + ["Shift+F10", "Shift+F11", "Shift+F12"] + .iter() + .map(|k| { + let label = match holder_of(s, k) { + Some(h) => format!("{k} (Replace “{h}”)"), + None => k.to_string(), + }; + let mut p = json!({"slot": k}); + if let Some(w) = cx.workspace { + p["workspace"] = json!(w); + } + dyn_entry(label, "window.assignWorkspaceShortcut", p) + }) + .collect(), + None, + ), + "openViewers" => ( + s.state + .open_comps + .iter() + .filter(|c| Some(**c) != s.state.active_comp) + .filter_map(|c| s.project.item(*c).map(|i| dyn_entry(format!("Composition: {}", i.name), "comp.open", json!({"comp": c.0})))) + .collect(), + None, + ), + _ => (vec![], None), + } +} + +/// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or +/// workspace, like After Effects). +pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String { + match label { + "Assign Shortcut to 3D View" => { + let v = s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default(); + let name = crate::commands::find(crate::commands::view_command(v)).map(|c| c.label).unwrap_or("Active Camera"); + format!("Assign Shortcut to “{name}”") + } + "Assign Shortcut to Workspace" => format!("Assign Shortcut to “{}” Workspace", cx.workspace.unwrap_or("Default")), + _ => label.to_string(), + } } /// A clickable menu entry. -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] pub struct MenuEntry { pub label: String, pub command: String, @@ -81,7 +192,7 @@ pub fn entries() -> Vec<(Vec, &'static MenuEntry)> { for n in nodes { match n { MenuNode::Item(e) => out.push((path.clone(), e)), - MenuNode::Separator => {} + MenuNode::Separator | MenuNode::Dynamic { .. } => {} MenuNode::Submenu { label, children } => { path.push(label.clone()); rec(children, path, out); @@ -97,7 +208,23 @@ pub fn entries() -> Vec<(Vec, &'static MenuEntry)> { /// Display label of an entry: label names come from Settings ▸ Labels. pub fn entry_label(s: &Session, e: &MenuEntry) -> String { + let comp_name = || s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()).unwrap_or_else(|| "(none)".into()); + let layer_name = + || s.active_comp().and_then(|c| s.state.selected_layers.first().and_then(|l| c.layer(*l))).map(|l| l.name.clone()).unwrap_or_else(|| "(none)".into()); match e.command.as_str() { + // Window menu: the panels' current viewer instances. + "window.panel" => match e.params.get("panel").and_then(Value::as_str) { + Some("composition") => format!("Composition: {}", comp_name()), + Some("timeline") => format!("Timeline: {}", comp_name()), + Some("flowchart") => format!("Flowchart: {}", comp_name()), + Some("layer") => format!("Layer: {}", layer_name()), + Some("effectControls") if e.label == "Effect Controls" => format!("Effect Controls: {}", layer_name()), + _ => e.label.clone(), + }, + "window.unavailablePanel" if e.params.get("panel").and_then(Value::as_str) == Some("footage") => { + let f = s.state.project_selection.iter().filter_map(|i| s.project.item(*i)).find(|i| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))); + format!("Footage: {}", f.map(|i| i.name.clone()).unwrap_or_else(|| "(none)".into())) + } "edit.label" => match e.params.get("label").and_then(Value::as_str).and_then(effectcraft_color::Label::from_name) { Some(l) if l != effectcraft_color::Label::None => s.prefs.label_name(l), _ => e.label.clone(), @@ -109,6 +236,9 @@ pub fn entry_label(s: &Session, e: &MenuEntry) -> String { /// Check-mark state of an entry (`None` = not a toggle / radio entry). Frontend-only toggles /// (rulers, grid…) are answered by the frontend. pub fn checked(s: &Session, command: &str, params: &Value) -> Option { + if command == "help.enableLogging" { + return Some(crate::logging::is_enabled()); + } let comp = s.active_comp()?; let layer = s.state.selected_layers.first().and_then(|l| comp.layer(*l)); let pstr = |k: &str| params.get(k).and_then(Value::as_str); @@ -248,6 +378,10 @@ pub fn parse(text: &str, mac: bool) -> Result, String> { out.extend(effect_categories()); continue; } + if let Some(name) = l.body.strip_prefix("@dynamic:") { + out.push(MenuNode::Dynamic { name: name.trim().to_string() }); + continue; + } let mut parts = l.body.split(" | "); let label = parts.next().unwrap_or_default().trim().to_string(); match parts.next() { @@ -333,6 +467,8 @@ File New Folder | project.newFolder Open Project... | file.open Open Recent + @dynamic:recentProjects + --- Clear Recent Projects | file.clearRecent --- Close | file.close @@ -341,6 +477,7 @@ File Save As Save As... | file.saveAs Save a Copy... | file.saveCopy + Save a Copy As XML... | file.saveCopyAsXml Increment and Save | file.incrementAndSave Revert | file.revert --- @@ -351,6 +488,10 @@ File Solid... | file.importSolid Lottie... | file.importLottie Essential Graphics Template... | essential.importTemplate + Import Recent Footage + @dynamic:recentFootage + --- + Clear Recent Footage | file.clearRecentFootage Export Add to Render Queue | renderQueue.add Lottie JSON... | file.exportLottie @@ -388,6 +529,7 @@ File File... | file.replaceFootage Placeholder... | file.replaceWithPlaceholder Solid... | file.replaceWithSolid + With Layered Comp | file.replaceWithLayeredComp Reload Footage | file.reloadFootage Reveal in Finder | file.revealInFinder --- @@ -397,6 +539,8 @@ File Edit Undo | edit.undo Redo | edit.redo + History + @dynamic:history --- Quick Apply... | app.commandPalette --- @@ -508,6 +652,10 @@ Composition --- Composition Flowchart | comp.flowchart Composition Mini-Flowchart | comp.miniFlowchart + --- + VR + Create VR Environment... | comp.vr.createEnvironment + Extract Cubemap... | comp.vr.extractCubemap Layer New Text | layer.newText @@ -760,6 +908,10 @@ Effect Animation Save Animation Preset... | anim.savePreset Apply Animation Preset... | anim.applyPreset + Recent Animation Presets + @dynamic:recentPresets + --- + Clear Recent Presets | anim.clearRecentPresets Browse Presets... | anim.browsePresets Text Animation Presets Typewriter | layer.applyTextPreset {"preset":"typewriter"} @@ -922,6 +1074,8 @@ View Custom View 3 | view.3d.custom3 --- Reset 3D View | view.reset3DView + Assign Shortcut to 3D View + @dynamic:view3dShortcuts Switch to Last 3D View | view.3d.last | Escape Look at Selected Layers | view.lookAtSelected Look at All Layers | view.lookAtAll @@ -951,6 +1105,8 @@ Window Save Changes to this Workspace | window.saveWorkspace Save as New Workspace... | window.saveWorkspaceAs Edit Workspaces... | window.editWorkspaces + Assign Shortcut to Workspace + @dynamic:workspaceShortcuts --- Align | window.panel {"panel":"align"} Audio | window.panel {"panel":"audio"} | Cmd+4 @@ -984,6 +1140,7 @@ Window Project | window.panel {"panel":"project"} | Cmd+0 Render Queue | window.panel {"panel":"renderQueue"} | Cmd+Alt+0 Timeline | window.panel {"panel":"timeline"} + @dynamic:openViewers Help EffectCraft Help... | help.docs {"page":"help"} | F1 Scripting Help... | help.docs {"page":"scripting"} @@ -992,6 +1149,10 @@ Help Animation Presets... | anim.browsePresets Keyboard Shortcuts... | app.keyboardShortcuts --- + System Compatibility Report... | help.systemReport + Enable Logging | help.enableLogging + Reveal Logging File | help.revealLogFile + --- Join the ArtCraft Discord... | help.discord Provide Feedback... | help.reportIssue --- diff --git a/crates/engine/src/menus/tests.rs b/crates/engine/src/menus/tests.rs index ee8790c7..6fdbd2a7 100644 --- a/crates/engine/src/menus/tests.rs +++ b/crates/engine/src/menus/tests.rs @@ -6,7 +6,7 @@ fn all_entries(nodes: &[MenuNode], path: &mut Vec, out: &mut Vec<(Vec out.push((path.clone(), e.clone())), - MenuNode::Separator => {} + MenuNode::Separator | MenuNode::Dynamic { .. } => {} MenuNode::Submenu { label, children } => { path.push(label.clone()); all_entries(children, path, out); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 2d744226..8a484601 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -13,6 +13,7 @@ use std::collections::BTreeMap; use effectcraft_color::Label; +use effectcraft_time::Tick; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; @@ -273,6 +274,13 @@ pub struct Prefs { pub scripting: Scripting, /// File ▸ Open Recent, newest first. pub recent_projects: Vec, + /// File ▸ Import Recent Footage, newest first. + pub recent_footage: Vec, + /// Animation ▸ Recent Animation Presets (`.ecpreset` paths or built-in preset ids), newest + /// first. + pub recent_presets: Vec, + /// Character panel: recently used font families, newest first. + pub recent_fonts: Vec, /// Top-level keys this version doesn't know (kept for newer versions). #[serde(flatten)] pub extra: Extra, @@ -301,6 +309,9 @@ impl Default for Prefs { three_d: ThreeD::default(), scripting: Scripting::default(), recent_projects: vec![], + recent_footage: vec![], + recent_presets: vec![], + recent_fonts: vec![], extra: Extra::new(), } } @@ -430,7 +441,13 @@ impl Prefs { pub fn reset(&mut self, page: Option<&str>) -> Result<(), String> { let d = Prefs::default(); let Some(page) = page else { - *self = Prefs { recent_projects: std::mem::take(&mut self.recent_projects), ..d }; + *self = Prefs { + recent_projects: std::mem::take(&mut self.recent_projects), + recent_footage: std::mem::take(&mut self.recent_footage), + recent_presets: std::mem::take(&mut self.recent_presets), + recent_fonts: std::mem::take(&mut self.recent_fonts), + ..d + }; return Ok(()); }; let keys: Vec<&str> = match page { @@ -490,6 +507,174 @@ impl Prefs { _ => 0.125, } } + + /// The Memory & CPU cache budgets in effect: the configured layer / footage / preview + /// budgets, scaled down so together they leave **RAM Reserved for Other Applications** free, + /// and halved while the system is low on memory when **Reduce Cache Size When System Is Low + /// on Memory** is on. `mem` = the system's memory (`None` = unknown: configured budgets). + pub fn cache_budgets(&self, mem: Option) -> CacheBudgets { + let (mut l, mut m, mut p) = (self.layer_cache_bytes() as u64, self.media_cache_bytes() as u64, self.preview_cache_bytes() as u64); + let floor = 64u64 << 20; + let mut capped = false; + let mut reduced = false; + if let Some(mem) = mem.filter(|m| m.total > 0) { + let reserved = (self.memory.ram_reserved_gb as u64) << 30; + let cap = mem.total.saturating_sub(reserved).max(3 * floor); + let sum = l + m + p; + if sum > cap { + let k = cap as f64 / sum as f64; + (l, m, p) = ((l as f64 * k) as u64, (m as f64 * k) as u64, (p as f64 * k) as u64); + capped = true; + } + if self.memory.reduce_cache_when_low && mem.is_low() { + (l, m, p) = (l / 2, m / 2, p / 2); + reduced = true; + } + } + CacheBudgets { layer: l.max(floor) as usize, media: m.max(floor) as usize, preview: p.max(floor) as usize, capped, reduced } + } + + /// Composition ▸ Motion Path: the layer-time span of a motion path to draw around `now` + /// (layer time), for keyframes at `keys` (sorted). `None` = No Motion Path. + pub fn motion_path_span(&self, keys: &[Tick], now: Tick) -> Option<(Tick, Tick)> { + let (first, last) = (*keys.first()?, *keys.last()?); + let c = &self.composition; + match c.motion_path.as_str() { + "none" => None, + "seconds" => { + let half = Tick::from_seconds_f64(c.motion_path_seconds.max(0.0) / 2.0); + let (a, b) = ((now - half).max(first), (now + half).min(last)); + (a < b).then_some((a, b)) + } + "keyframes" => { + let n = (c.motion_path_keyframes.max(1) as usize).min(keys.len()); + if n < 2 { + return None; + } + // The n keyframes centred on the current time. + let at = keys.partition_point(|k| *k < now); + let start = at.saturating_sub(n / 2).min(keys.len() - n); + Some((keys[start], keys[start + n - 1])) + } + _ => Some((first, last)), + } + } + + /// Previews ▸ Viewer Zoom Quality: smooth (bilinear) scaling of the viewer image, or nearest + /// neighbour ("Faster"). + pub fn viewer_zoom_smooth(&self) -> bool { + self.previews.zoom_quality != "faster" + } + + /// Import ▸ Interpret Unlabeled Alpha As, for footage whose file has an alpha channel that + /// doesn't state how it is stored. `None` = Ask User (keep straight and open Interpret + /// Footage); `guess` takes premultiplied for movies (codecs with alpha are usually written + /// premultiplied) and straight for stills. + pub fn unlabeled_alpha(&self, movie: bool) -> Option { + use effectcraft_project::AlphaMode::*; + Some(match self.import.unlabeled_alpha.as_str() { + "ignore" => Ignore, + "straight" => Straight, + "premultiplied" => Premultiplied, + "guess" if movie => Premultiplied, + "guess" => Straight, + _ => return None, + }) + } + + /// Import ▸ Default Drag Import As, as `file.import`'s `importAs`. + pub fn drag_import_as(&self) -> &'static str { + match self.import.drag_import_as.as_str() { + "comp" => "composition", + "compLayerSizes" => "compositionLayerSizes", + _ => "footage", + } + } + + /// Export ▸ Append Bit Depth to File Name: `Comp 1.png` → `Comp 1_16bpc.png` (sequence frame + /// runs and folders are kept). + pub fn output_name(&self, path: &str, bits: u32) -> String { + if !self.export.append_bits_to_name { + return path.to_string(); + } + let p = std::path::Path::new(path); + let (Some(stem), dir) = (p.file_stem().map(|s| s.to_string_lossy().to_string()), p.parent()) else { return path.to_string() }; + let tag = format!("_{bits}bpc"); + if stem.ends_with(&tag) { + return path.to_string(); + } + let name = match p.extension() { + Some(e) => format!("{stem}{tag}.{}", e.to_string_lossy()), + None => format!("{stem}{tag}"), + }; + match dir.filter(|d| !d.as_os_str().is_empty()) { + Some(d) => d.join(name).to_string_lossy().to_string(), + None => name, + } + } + + /// Export ▸ Segment Sequences / Segment Movie Files: how many frames go in each segment of an + /// output of `frames` frames (`None` = one file / folder). Sequences split every Files per + /// Segment frames; movies every Segment Size MB at the output's data rate + /// (`bytes_per_frame`, estimated from the format's bitrate). + pub fn segment_frames(&self, frames: u64, sequence: bool, bytes_per_frame: f64) -> Option { + let e = &self.export; + let per = if sequence { + e.segment_sequences.then_some(e.segment_sequence_files.max(1) as u64)? + } else { + if !e.segment_movies || bytes_per_frame <= 0.0 { + return None; + } + (((e.segment_movie_mb.max(1) as f64) * 1_048_576.0 / bytes_per_frame).floor() as u64).max(1) + }; + (per < frames).then_some(per) + } + + /// Remember a font in the Character panel's recent fonts (Type ▸ Number of Recent Fonts). + pub fn push_recent_font(&mut self, family: &str) { + push_mru(&mut self.recent_fonts, family, 30); + } + + /// The recent fonts the Character panel shows (at most Number of Recent Fonts to Display). + pub fn recent_fonts_shown(&self) -> &[String] { + let n = (self.type_.recent_fonts as usize).min(self.recent_fonts.len()); + &self.recent_fonts[..n] + } + + /// Remember imported footage for File ▸ Import Recent Footage. + pub fn push_recent_footage(&mut self, path: &str) { + push_mru(&mut self.recent_footage, path, self.general.recent_items as usize); + } + + /// Remember an applied animation preset for Animation ▸ Recent Animation Presets. + pub fn push_recent_preset(&mut self, path: &str) { + push_mru(&mut self.recent_presets, path, self.general.recent_items as usize); + } +} + +fn push_mru(list: &mut Vec, v: &str, max: usize) { + list.retain(|x| x != v); + list.insert(0, v.to_string()); + list.truncate(max.max(1)); +} + +/// Effective cache budgets in bytes (see [`Prefs::cache_budgets`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct CacheBudgets { + pub layer: usize, + pub media: usize, + pub preview: usize, + /// Scaled down to leave the reserved RAM free. + pub capped: bool, + /// Halved because the system is low on memory. + pub reduced: bool, +} + +/// Colour of the `k`-th mask of a layer: Appearance ▸ Cycle Mask Colors cycles through the mask +/// colours, otherwise every new mask takes the first one. +pub fn mask_color(cycle: bool, k: usize) -> [u8; 3] { + use effectcraft_project::build::MASK_COLORS; + if cycle { MASK_COLORS[k % MASK_COLORS.len()] } else { MASK_COLORS[0] } } fn label_index(l: Label) -> Option { @@ -659,13 +844,14 @@ pub fn pages() -> Vec { Section("Options"), s("general.showToolTips", "Show Tool Tips", B, true), s("general.createLayersAtCompStart", "Create Layers at Composition Start Time", B, true), - s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, false), + s("general.switchesAffectNestedComps", "Switches Affect Nested Comps", B, true), s("general.defaultSpatialLinear", "Default Spatial Interpolation to Linear", B, true), - s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, false), - s("general.syncTimeRelatedItems", "Synchronize Time of All Related Items", B, false), - s("general.expressionPickWhipCompact", "Expression Pick Whip Writes Compact English", B, false), - s("general.createSplitLayersAbove", "Create Split Layers Above Original Layer", B, false), + s("general.preserveConstantVertexCount", "Preserve Constant Vertex and Feather Point Count when Editing Masks", B, true), + s("general.syncTimeRelatedItems", "Synchronize Time of All Related Items", B, true), + s("general.expressionPickWhipCompact", "Expression Pick Whip Writes Compact English", B, true), + s("general.createSplitLayersAbove", "Create Split Layers Above Original Layer", B, true), s("general.useSystemColorPicker", "Use System Color Picker", B, false), + Note("EffectCraft always uses its own colour picker (the same on every platform and the web)."), ], }, Page { @@ -673,7 +859,7 @@ pub fn pages() -> Vec { title: "Startup & Repair", items: vec![ s("startup.showHomeOnLaunch", "Show Home Screen When Launching", B, true), - s("startup.showHomeOnOpenProject", "Show Home Screen When Opening a Project", B, false), + s("startup.showHomeOnOpenProject", "Show Home Screen When Opening a Project", B, true), s("startup.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true), Section("Repair"), Button { label: "Reset Settings", command: "prefs.reset", params: "{}" }, @@ -712,13 +898,14 @@ pub fn pages() -> Vec { ("No More Than (Seconds)", "seconds"), ("No More Than (Keyframes)", "keyframes"), ]), - false, + true, ), - s("composition.motionPathSeconds", "Seconds", Kind::Float(0.0, 3600.0, "s"), false), - s("composition.motionPathKeyframes", "Keyframes", Kind::Int(1, 10_000, ""), false), + s("composition.motionPathSeconds", "Seconds", Kind::Float(0.0, 3600.0, "s"), true), + s("composition.motionPathKeyframes", "Keyframes", Kind::Int(1, 10_000, ""), true), Section("Display"), s("composition.showRenderingProgress", "Show Rendering Progress in Info Panel and Flowchart", B, true), s("composition.hardwareAcceleratePanels", "Hardware Accelerate Composition, Layer and Footage Panels", B, false), + Note("Panels are always drawn by the GPU (wgpu)."), ], }, Page { @@ -726,10 +913,10 @@ pub fn pages() -> Vec { title: "Previews", items: vec![ s("previews.adaptiveResolutionLimit", "Adaptive Resolution Limit", Kind::Choice(ON_OFF_RES), true), - s("previews.showInternalWireframes", "Show Internal Wireframes", B, false), + s("previews.showInternalWireframes", "Show Internal Wireframes", B, true), s("previews.cacheFramesWhenIdle", "Cache Frames When Idle", B, true), s("previews.fastPreviews", "Fast Previews (Draft 3D, Faster Effects)", B, true), - s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), false), + s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), true), s("previews.displayProfile", "Display Color Space", Kind::Choice(&[("sRGB", "srgb"), ("Display P3", "p3")]), true), Button { label: "GPU Information...", command: "app.gpuInfo", params: "{}" }, ], @@ -742,9 +929,9 @@ pub fn pages() -> Vec { s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true), Section("Labels and Colors"), s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true), - s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, false), - s("appearance.cycleMaskColors", "Cycle Mask Colors", B, false), - s("appearance.useGradients", "Use Gradients", B, false), + s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true), + s("appearance.cycleMaskColors", "Cycle Mask Colors", B, true), + s("appearance.useGradients", "Use Gradients", B, true), Section("Menu Bar"), s("appearance.inWindowMenuBarMac", "Use In-Window Menu Bar on macOS", B, true), ], @@ -757,13 +944,13 @@ pub fn pages() -> Vec { s("grids.gridColor", "Color", Kind::Color, true), s("grids.gridSpacing", "Gridline Every", Kind::Float(1.0, 10_000.0, "px"), true), s("grids.gridSubdivisions", "Subdivisions", Kind::Int(1, 100, ""), true), - s("grids.gridStyle", "Style", Kind::Choice(STYLES), false), + s("grids.gridStyle", "Style", Kind::Choice(STYLES), true), Section("Proportional Grid"), - s("grids.proportionalHorizontal", "Horizontal", Kind::Int(1, 100, ""), false), - s("grids.proportionalVertical", "Vertical", Kind::Int(1, 100, ""), false), + s("grids.proportionalHorizontal", "Horizontal", Kind::Int(1, 100, ""), true), + s("grids.proportionalVertical", "Vertical", Kind::Int(1, 100, ""), true), Section("Guides"), s("grids.guideColor", "Color", Kind::Color, true), - s("grids.guideStyle", "Style", Kind::Choice(STYLES), false), + s("grids.guideStyle", "Style", Kind::Choice(STYLES), true), Section("Safe Margins"), s("grids.actionSafe", "Action-safe", Kind::Float(0.0, 50.0, "%"), true), s("grids.titleSafe", "Title-safe", Kind::Float(0.0, 50.0, "%"), true), @@ -774,10 +961,10 @@ pub fn pages() -> Vec { id: "type", title: "Type", items: vec![ - s("type.textEngine", "Text Engine", Kind::Choice(&[("Latin", "latin"), ("South Asian and Middle Eastern", "southAsian")]), false), - s("type.fontPreview", "Show Font Preview", B, false), - s("type.recentFonts", "Number of Recent Fonts to Display", Kind::Int(0, 30, ""), false), - s("type.fontNamesInEnglish", "Show Font Names in English", B, false), + s("type.textEngine", "Text Engine", Kind::Choice(&[("Latin", "latin"), ("South Asian and Middle Eastern", "southAsian")]), true), + s("type.fontPreview", "Show Font Preview", B, true), + s("type.recentFonts", "Number of Recent Fonts to Display", Kind::Int(0, 30, ""), true), + s("type.fontNamesInEnglish", "Show Font Names in English", B, true), ], }, Page { @@ -787,7 +974,7 @@ pub fn pages() -> Vec { s("import.stillFootage", "Still Footage", Kind::Choice(&[("Length of Composition", "compLength"), ("Duration (Seconds)", "seconds")]), true), s("import.stillSeconds", "Still Duration", Kind::Float(0.04, 86_400.0, "s"), true), s("import.sequenceFps", "Sequence Footage", Kind::Float(1.0, 999.0, "frames per second"), true), - s("import.reportMissingFrames", "Report Missing Frames", B, false), + s("import.reportMissingFrames", "Report Missing Frames", B, true), s( "import.unlabeledAlpha", "Interpret Unlabeled Alpha As", @@ -798,13 +985,13 @@ pub fn pages() -> Vec { ("Straight", "straight"), ("Premultiplied", "premultiplied"), ]), - false, + true, ), s( "import.dragImportAs", "Default Drag Import As", Kind::Choice(&[("Footage", "footage"), ("Composition", "comp"), ("Composition - Retain Layer Sizes", "compLayerSizes")]), - false, + true, ), ], }, @@ -812,12 +999,12 @@ pub fn pages() -> Vec { id: "export", title: "Export", items: vec![ - s("export.defaultOutputFolder", "Default Output Folder", Kind::Path, false), - s("export.segmentSequences", "Segment Sequences", B, false), - s("export.segmentSequenceFiles", "Files per Segment", Kind::Int(1, 100_000, "files"), false), - s("export.segmentMovies", "Segment Movie Files", B, false), - s("export.segmentMovieMb", "Segment Size", Kind::Int(1, 1_000_000, "MB"), false), - s("export.appendBitsToName", "Append Bit Depth to File Name", B, false), + s("export.defaultOutputFolder", "Default Output Folder", Kind::Path, true), + s("export.segmentSequences", "Segment Sequences", B, true), + s("export.segmentSequenceFiles", "Files per Segment", Kind::Int(1, 100_000, "files"), true), + s("export.segmentMovies", "Segment Movie Files", B, true), + s("export.segmentMovieMb", "Segment Size", Kind::Int(1, 1_000_000, "MB"), true), + s("export.appendBitsToName", "Append Bit Depth to File Name", B, true), ], }, Page { @@ -826,7 +1013,7 @@ pub fn pages() -> Vec { items: vec![ Section("Audio Hardware"), AudioDevices { key: "audio.outputDevice" }, - s("audio.previewSampleRate", "Preview Sample Rate", Kind::Int(8_000, 192_000, "Hz"), false), + s("audio.previewSampleRate", "Preview Sample Rate", Kind::Int(8_000, 192_000, "Hz"), true), Section("Audio Output Mapping"), s("audio.outputLeft", "Left", Kind::Int(1, 64, "channel"), true), s("audio.outputRight", "Right", Kind::Int(1, 64, "channel"), true), @@ -837,36 +1024,36 @@ pub fn pages() -> Vec { title: "Disk", items: vec![ Section("Disk Cache"), - s("disk.diskCacheEnabled", "Enable Disk Cache", B, false), - s("disk.diskCacheMaxGb", "Maximum Disk Cache Size", Kind::Int(1, 100_000, "GB"), false), - s("disk.diskCacheFolder", "Disk Cache Folder", Kind::Path, false), + s("disk.diskCacheEnabled", "Enable Disk Cache", B, true), + s("disk.diskCacheMaxGb", "Maximum Disk Cache Size", Kind::Int(1, 100_000, "GB"), true), + s("disk.diskCacheFolder", "Disk Cache Folder", Kind::Path, true), Button { label: "Empty Disk Cache", command: "edit.purge", params: r#"{"what":"disk"}"# }, Section("Media Cache"), - s("disk.mediaCacheFolder", "Database and Cache Folder", Kind::Path, false), - s("disk.conformedMediaFolder", "Conformed Audio Folder", Kind::Path, false), + s("disk.mediaCacheFolder", "Database and Cache Folder", Kind::Path, true), + s("disk.conformedMediaFolder", "Conformed Audio Folder", Kind::Path, true), ], }, Page { id: "memory", title: "Memory & CPU", items: vec![ - s("memory.ramReservedGb", "RAM Reserved for Other Applications", Kind::Int(0, 1024, "GB"), false), + s("memory.ramReservedGb", "RAM Reserved for Other Applications", Kind::Int(0, 1024, "GB"), true), Section("Cache Budgets"), s("memory.layerCacheMb", "Layer Cache", Kind::Int(64, 1 << 20, "MB"), true), s("memory.mediaCacheMb", "Footage Frame Cache", Kind::Int(64, 1 << 20, "MB"), true), s("memory.previewCacheMb", "Preview (RAM) Cache", Kind::Int(64, 1 << 20, "MB"), true), - s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, false), + s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, true), ], }, Page { id: "video", title: "Video", items: vec![ - s("video.enableOutput", "Enable Video Preview Output", B, false), - s("video.device", "Video Device", Kind::Text, false), - s("video.outputDuringPlayback", "Video Output During Playback", B, false), - s("video.mirrorOnMonitor", "Mirror on Computer Monitor", B, false), - s("video.disableWhenBackground", "Disable Video Output When in Background", B, false), + s("video.enableOutput", "Enable Video Preview Output", B, true), + s("video.device", "Video Device", Kind::Choice(&[("Floating Window", ""), ("Full Screen", "fullscreen")]), true), + s("video.outputDuringPlayback", "Video Output During Playback", B, true), + s("video.mirrorOnMonitor", "Mirror on Computer Monitor", B, true), + s("video.disableWhenBackground", "Disable Video Output When in Background", B, true), ], }, Page { @@ -874,9 +1061,9 @@ pub fn pages() -> Vec { title: "3D", items: vec![ s("threeD.defaultRenderer", "Default 3D Renderer", Kind::Choice(&[("Classic 3D", "classic"), ("Advanced 3D", "advanced")]), true), - s("threeD.showReferenceAxes", "Show 3D Reference Axes", B, false), - s("threeD.extendedViewer", "Extended Viewer", B, false), - s("threeD.realtimeShadows", "Realtime Shadows in Draft", B, false), + s("threeD.showReferenceAxes", "Show 3D Reference Axes", B, true), + s("threeD.extendedViewer", "Extended Viewer", B, true), + s("threeD.realtimeShadows", "Realtime Shadows in Draft", B, true), ], }, Page { @@ -884,18 +1071,18 @@ pub fn pages() -> Vec { title: "Scripting & Expressions", items: vec![ Section("Application Scripting"), - s("scripting.allowScriptsWriteFiles", "Allow Scripts to Write Files and Access Network", B, false), - s("scripting.warnExecutingFiles", "Warn User When Executing Files", B, false), + s("scripting.allowScriptsWriteFiles", "Allow Scripts to Write Files and Access Network", B, true), + s("scripting.warnExecutingFiles", "Warn User When Executing Files", B, true), s("scripting.enableJsDebugger", "Enable JavaScript Debugger", B, false), Section("Expressions Editor"), - s("scripting.editorFontSize", "Font Size", Kind::Int(8, 40, "pt"), false), - s("scripting.syntaxHighlighting", "Syntax Highlighting", B, false), - s("scripting.lineNumbers", "Line Numbers", B, false), - s("scripting.autoComplete", "Auto-complete", B, false), - s("scripting.bracketMatching", "Bracket Matching", B, false), - s("scripting.wordWrap", "Word Wrap", B, false), - s("scripting.errorBanner", "Show Expression Error Banner", B, false), - Note("Expressions use the JavaScript engine."), + s("scripting.editorFontSize", "Font Size", Kind::Int(8, 40, "pt"), true), + s("scripting.syntaxHighlighting", "Syntax Highlighting", B, true), + s("scripting.lineNumbers", "Line Numbers", B, true), + s("scripting.autoComplete", "Auto-complete", B, true), + s("scripting.bracketMatching", "Bracket Matching", B, true), + s("scripting.wordWrap", "Word Wrap", B, true), + s("scripting.errorBanner", "Show Expression Error Banner", B, true), + Note("Expressions use the JavaScript engine. Script errors report their file and line in the Script Console; there is no step debugger."), ], }, ] diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 9dd20e8d..c01d8513 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -10,7 +10,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template}; +use effectcraft_project::render_queue::{OutputFormat, OutputModule, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template}; use effectcraft_project::{ItemId, ItemKind, LayerSource, Project}; use effectcraft_render::{ExprHost, FootageSource}; use serde::{Deserialize, Serialize}; @@ -27,6 +27,8 @@ pub struct ExportJob<'a> { pub accel: Option<&'a dyn effectcraft_render::Accelerator>, /// Resolved output path (templates expanded except the `#` frame-number run). pub path: &'a str, + /// Settings ▸ General ▸ Switches Affect Nested Comps. + pub nested_switches: bool, } /// What an export wrote. @@ -127,8 +129,56 @@ struct Work { expr: Option>, accel: Option>, exporter: Arc, - /// Per queue item: one (item with that output module, resolved path) per output module. + /// Per queue item: one (item with that output module, resolved path) per output module + /// (and per segment, see [`Session::segments`]). items: Vec>, + /// Settings ▸ General ▸ Switches Affect Nested Comps. + nested_switches: bool, +} + +/// Bits per channel an output format writes for a project bit depth (named in the file when +/// Settings ▸ Export ▸ Append Bit Depth to File Name is on). +pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) -> u32 { + use effectcraft_project::BitDepth; + let project = match depth { + BitDepth::Bpc8 => 8, + BitDepth::Bpc16 => 16, + BitDepth::Bpc32 => 32, + }; + match format { + OutputFormat::ExrSequence => 32, + OutputFormat::PngSequence | OutputFormat::TiffSequence => project.min(16), + OutputFormat::ProRes => 10, + OutputFormat::Wav | OutputFormat::Aiff => 16, + _ => 8, + } +} + +/// The bytes an output writes per frame, estimated from its data rate (H.264 bitrate, Apple's +/// published ProRes target rates at 1080p29.97 scaled by frame size, typical rates otherwise). +pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 { + use effectcraft_project::render_queue::ProResProfile::*; + let fps = fps.max(1.0); + let px = (w as f64 * h as f64) / (1920.0 * 1080.0); + let mbps = |m: f64| m * 1_000_000.0 / 8.0; + match om.format { + OutputFormat::H264 => om.bitrate_kbps as f64 * 1000.0 / 8.0 / fps, + OutputFormat::ProRes => { + let rate = match om.prores_profile { + Proxy => 45.0, + Lt => 102.0, + Standard => 147.0, + Hq => 220.0, + P4444 => 330.0, + P4444Xq => 500.0, + }; + mbps(rate) * px / 29.97 + } + OutputFormat::WebM => mbps(8.0) * px / fps, + OutputFormat::Gif => w as f64 * h as f64 * 0.5, + OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps, + _ => w as f64 * h as f64 * 3.0, + } } fn unix_now() -> u64 { @@ -143,7 +193,15 @@ pub(crate) fn run_items( shared: &JobShared, notify: &mut dyn FnMut(&JobShared), ) { - let work = Work { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), accel: s.accel.clone(), exporter, items }; + let work = Work { + project: s.project.clone(), + footage: s.footage.clone(), + expr: s.expr.clone(), + accel: s.accel.clone(), + exporter, + items, + nested_switches: s.prefs.general.switches_affect_nested_comps, + }; run_work(work, shared, notify); } @@ -174,7 +232,15 @@ fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) { // first module's file is the item's output. let mut r: Result = Err("no output module".into()); for (mi, (mitem, path)) in modules.iter().enumerate() { - let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path }; + let job = ExportJob { + project: &w.project, + footage: w.footage.as_ref(), + expr: w.expr.as_deref(), + accel: w.accel.as_deref(), + item: mitem, + path, + nested_switches: w.nested_switches, + }; let rr = w.exporter.export(&job, &mut |done, total| { { let mut s = lock(&shared.state); @@ -248,12 +314,58 @@ impl Session { if path.is_absolute() { return Some(p); } - let base = self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty()); + // Settings ▸ Export ▸ Default Output Folder, else the project's folder. + let folder = self.prefs.export.default_output_folder.trim(); + let base = (!folder.is_empty()).then(|| std::path::PathBuf::from(folder)); + let base = + base.or_else(|| self.path.as_deref().and_then(|s| std::path::Path::new(s).parent().map(|d| d.to_path_buf())).filter(|d| !d.as_os_str().is_empty())); // (the web has no working directory: relative outputs land in its virtual root) let base = base.or_else(|| std::env::current_dir().ok()).or_else(|| cfg!(target_arch = "wasm32").then(|| "/".into()))?; Some(base.join(path).to_string_lossy().to_string()) } + /// Settings ▸ Export ▸ Segment Sequences / Segment Movie Files: the (item, path) outputs one + /// output module writes. Sequences go into numbered folders of Files per Segment frames each + /// (`out/Comp 1_001/Comp 1_00000.png`…); movies into numbered files of about Segment Size MB + /// each (`Comp 1_001.mp4`…), cut at the frame count the output's data rate gives. + pub fn segments(&self, item: &RenderQueueItem, path: &str) -> Vec<(RenderQueueItem, String)> { + let Some(comp) = self.project.comp(item.comp) else { return vec![(item.clone(), path.to_string())] }; + let fmt = item.output.format; + let frames = item.settings.frame_count(comp); + let (w, h) = item.settings.output_size(comp); + let bpf = bytes_per_frame(&item.output, w, h, item.settings.rate(comp).as_f64()); + let Some(per) = self.prefs.segment_frames(frames, fmt.is_sequence(), bpf) else { return vec![(item.clone(), path.to_string())] }; + let p = std::path::Path::new(path); + let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default(); + let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + let ext = p.extension().map(|s| s.to_string_lossy().to_string()); + let rate = item.settings.rate(comp); + let f0 = item.settings.first_frame(comp); + let mut out = vec![]; + let mut i = 0; + let mut k = 1; + while i < frames { + let n = per.min(frames - i); + let mut m = item.clone(); + let (start, end) = (rate.tick_of(f0 + i as i64), rate.tick_of(f0 + (i + n) as i64)); + m.settings.time_span = effectcraft_project::render_queue::TimeSpan::Custom { start, end }; + let seg = if fmt.is_sequence() { + let base = stem.trim_end_matches(['#', '[', ']', '_']).to_string(); + let folder = dir.join(format!("{base}_{k:03}")); + folder.join(p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default()) + } else { + dir.join(match &ext { + Some(e) => format!("{stem}_{k:03}.{e}"), + None => format!("{stem}_{k:03}"), + }) + }; + out.push((m, seg.to_string_lossy().to_string())); + i += n; + k += 1; + } + out + } + /// Whether a render job is running. pub fn is_rendering(&self) -> bool { self.render_job.as_ref().is_some_and(|j| !j.is_finished()) @@ -286,7 +398,13 @@ impl Session { let mut m = it.clone(); m.output = om.clone(); match self.resolve_output(&m) { - Some(p) if formats.contains(&om.format) => modules.push((m, p)), + Some(p) if formats.contains(&om.format) => { + // Settings ▸ Export: Append Bit Depth to File Name, Segment Sequences / + // Movie Files. + let bits = output_bits(om.format, self.project.settings.bit_depth); + let p = self.prefs.output_name(&p, bits); + modules.extend(self.segments(&m, &p)); + } Some(_) => problem = Some(RenderStatus::Failed(format!("{} export is not available", om.format.label()))), None if self.project.comp(it.comp).is_none() => problem = Some(RenderStatus::Failed("composition missing".into())), None => problem = Some(RenderStatus::NeedsOutput), @@ -329,7 +447,15 @@ impl Session { self.poll_render(); return Ok(ids); } - let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items }; + let work = Work { + project: self.project.clone(), + footage: self.footage.clone(), + expr: self.expr.clone(), + accel: self.accel.clone(), + exporter, + items, + nested_switches: self.prefs.general.switches_affect_nested_comps, + }; let shared = Arc::new(JobShared::default()); if wait { run_work(work, &shared, &mut |_| {}); diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 1a3370e4..d68496a2 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -25,9 +25,15 @@ impl Session { /// Settings changed: apply what the engine owns (cache budgets) and tell frontends. pub fn prefs_changed(&mut self) { self.prefs.normalize(); - self.layer_cache.set_budget(self.prefs.layer_cache_bytes()); - self.footage.set_cache_budget(self.prefs.media_cache_bytes()); + self.apply_cache_budgets(); self.configure_disk_cache(); + self.footage.set_conform_folder(self.conformed_audio_folder()); + // Switches Affect Nested Comps changes what precomp layers render: drop cached pixels. + let nested = self.prefs.general.switches_affect_nested_comps; + if self.applied_nested_switches.replace(nested).is_some_and(|was| was != nested) { + self.layer_cache.clear(); + self.events.push(crate::Event::PurgeCaches); + } // Fewer undo levels apply right away. let levels = self.prefs.general.undo_levels.max(1) as usize; if self.history.undo.len() > levels { @@ -37,6 +43,53 @@ impl Session { self.prefs_revision += 1; } + /// A Disk settings folder: the setting, or (for frontends with a settings store) `name` next + /// to the platform disk cache folder. Headless sessions without the setting stay off disk. + fn cache_folder(&self, setting: &str, name: &str) -> Option { + let s = setting.trim(); + if !s.is_empty() { + return Some(std::path::PathBuf::from(s)); + } + let base = effectcraft_render::disk_cache::default_folder(); + self.config.is_some().then(|| base.parent().map(|p| p.join(name)).unwrap_or_else(|| base.join(name))) + } + + /// Settings ▸ Disk ▸ Database and Cache Folder (media cache: audio waveform summaries). + pub fn media_cache_folder(&self) -> Option { + self.cache_folder(&self.prefs.disk.media_cache_folder, "Media Cache") + } + + /// Settings ▸ Disk ▸ Conformed Audio Folder (decoded footage audio, see + /// `MediaPool::set_conform_folder`). + pub fn conformed_audio_folder(&self) -> Option { + self.cache_folder(&self.prefs.disk.conformed_media_folder, "Conformed Audio") + } + + /// Apply the Memory & CPU cache budgets, limited by RAM Reserved for Other Applications and + /// reduced while the system is low on memory (see [`crate::prefs::Prefs::cache_budgets`]). + pub fn apply_cache_budgets(&mut self) { + let b = self.prefs.cache_budgets(self.sys_memory); + self.layer_cache.set_budget(b.layer); + self.footage.set_cache_budget(b.media); + } + + /// Re-read the system's memory (desktop frontends call this every few seconds) and re-apply + /// the cache budgets when the reading changes them. Returns the effective budgets. + pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets { + let before = self.prefs.cache_budgets(self.sys_memory); + self.sys_memory = crate::sysinfo::memory().or(self.sys_memory); + let after = self.prefs.cache_budgets(self.sys_memory); + if after != before { + self.apply_cache_budgets(); + // Frontends re-apply their preview cache budget. + self.prefs_revision += 1; + if after.reduced && !before.reduced { + self.toast("The system is low on memory: cache sizes were reduced"); + } + } + after + } + /// Apply Settings ▸ Media & Disk Cache ▸ Disk Cache: open (or close) the persistent cache /// and attach it to the layer cache. Without a folder setting the platform cache folder is /// used, but only by frontends with a settings store (headless sessions stay off disk). diff --git a/crates/engine/src/sysinfo.rs b/crates/engine/src/sysinfo.rs new file mode 100644 index 00000000..0c79c2a1 --- /dev/null +++ b/crates/engine/src/sysinfo.rs @@ -0,0 +1,140 @@ +//! Host information for Settings ▸ Memory & CPU (RAM reserved for other applications, reducing +//! caches when the system is low on memory) and Help ▸ System Compatibility Report. +//! +//! Pure Rust and dependency-free: the values come from the operating system's own reporting +//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every +//! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their +//! configured budgets. + +use serde::Serialize; + +/// Physical memory in bytes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +pub struct SysMemory { + pub total: u64, + /// Memory the system can hand out without swapping (free + reclaimable caches). + pub available: u64, +} + +impl SysMemory { + /// The system is low on memory: less than 10% (or less than 1 GB) is available. + pub fn is_low(&self) -> bool { + self.total > 0 && (self.available < self.total / 10 || self.available < 1 << 30) + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[allow(dead_code)] +fn run(cmd: &str, args: &[&str]) -> Option { + let out = std::process::Command::new(cmd).args(args).output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string()) +} + +/// Parse `/proc/meminfo` (kB values). +pub fn parse_meminfo(text: &str) -> Option { + let field = |name: &str| -> Option { + let line = text.lines().find(|l| l.starts_with(name))?; + let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?; + Some(kb * 1024) + }; + let total = field("MemTotal:")?; + let available = field("MemAvailable:").or_else(|| Some(field("MemFree:")? + field("Cached:").unwrap_or(0)))?; + Some(SysMemory { total, available }) +} + +/// Parse macOS `vm_stat` output: free + inactive + speculative + purgeable pages. +pub fn parse_vm_stat(text: &str, total: u64) -> Option { + let page: u64 = text.split("page size of ").nth(1)?.split_whitespace().next()?.parse().ok()?; + let pages = |name: &str| -> u64 { + text.lines() + .find(|l| l.starts_with(name)) + .and_then(|l| l.rsplit(':').next()) + .and_then(|v| v.trim().trim_end_matches('.').parse::().ok()) + .unwrap_or(0) + }; + let free = pages("Pages free") + pages("Pages inactive") + pages("Pages speculative") + pages("Pages purgeable"); + Some(SysMemory { total, available: (free * page).min(total) }) +} + +/// Total and available physical memory, when the system reports them. +pub fn memory() -> Option { + #[cfg(target_os = "linux")] + { + return parse_meminfo(&std::fs::read_to_string("/proc/meminfo").ok()?); + } + #[cfg(target_os = "macos")] + { + let total: u64 = run("sysctl", &["-n", "hw.memsize"])?.trim().parse().ok()?; + return parse_vm_stat(&run("vm_stat", &[])?, total).or(Some(SysMemory { total, available: total })); + } + #[cfg(target_os = "windows")] + { + let text = run( + "powershell", + &["-NoProfile", "-Command", "$o=Get-CimInstance Win32_OperatingSystem; \"$($o.TotalVisibleMemorySize) $($o.FreePhysicalMemory)\""], + )?; + let mut it = text.split_whitespace().filter_map(|v| v.parse::().ok()); + return Some(SysMemory { total: it.next()? * 1024, available: it.next()? * 1024 }); + } + #[allow(unreachable_code)] + None +} + +/// CPU model name. +pub fn cpu_name() -> Option { + #[cfg(target_os = "linux")] + { + let t = std::fs::read_to_string("/proc/cpuinfo").ok()?; + return t.lines().find(|l| l.starts_with("model name")).and_then(|l| l.split(':').nth(1)).map(|s| s.trim().to_string()); + } + #[cfg(target_os = "macos")] + { + return run("sysctl", &["-n", "machdep.cpu.brand_string"]).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + } + #[cfg(target_os = "windows")] + { + return std::env::var("PROCESSOR_IDENTIFIER").ok(); + } + #[allow(unreachable_code)] + None +} + +/// Operating system name and version. +pub fn os_version() -> String { + #[cfg(target_os = "macos")] + if let Some(v) = run("sw_vers", &["-productVersion"]) { + return format!("macOS {}", v.trim()); + } + #[cfg(target_os = "linux")] + if let Ok(t) = std::fs::read_to_string("/etc/os-release") + && let Some(n) = t.lines().find_map(|l| l.strip_prefix("PRETTY_NAME=")) + { + return n.trim_matches('"').to_string(); + } + #[cfg(target_os = "windows")] + if let Some(v) = run("cmd", &["/C", "ver"]) { + return v.trim().to_string(); + } + format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH) +} + +/// Logical CPU cores. +pub fn cpu_cores() -> usize { + std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_proc_meminfo_and_vm_stat() { + let m = parse_meminfo("MemTotal: 16000000 kB\nMemFree: 1000 kB\nMemAvailable: 8000000 kB\n").unwrap(); + assert_eq!(m, SysMemory { total: 16_000_000 * 1024, available: 8_000_000 * 1024 }); + assert!(!m.is_low()); + let vm = "Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 1000.\nPages active: 5.\nPages inactive: 3000.\nPages speculative: 100.\n"; + let m = parse_vm_stat(vm, 8 << 30).unwrap(); + assert_eq!(m.available, 4100 * 16384); + assert!(m.is_low()); + } +} diff --git a/crates/engine/src/tests_m145.rs b/crates/engine/src/tests_m145.rs new file mode 100644 index 00000000..ee278d8a --- /dev/null +++ b/crates/engine/src/tests_m145.rs @@ -0,0 +1,666 @@ +//! M14.5: settings that change behaviour, the keyframe assistants and labels, and the menu +//! entries added for After Effects parity (History, recent footage / presets, XML copies, VR, +//! shortcut slots, logging and the compatibility report). + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use crate::render::RenderOpts; +use crate::{KeyRef, Session}; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{ItemId, LayerId}; +use effectcraft_time::Tick; + +fn tmp(name: &str) -> PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static N: AtomicU32 = AtomicU32::new(0); + let d = std::env::temp_dir().join(format!("ec-m145-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d.join(name) +} + +fn session() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 48, "duration": 2, "frameRate": 10})).unwrap(); + s +} + +fn solid(s: &mut Session, color: &str) -> u64 { + s.execute("layer.newSolid", json!({"color": color, "width": 32, "height": 24})).unwrap()["layer"].as_u64().unwrap() +} + +fn layer(s: &Session, id: u64) -> effectcraft_project::Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone() +} + +// ---------------------------------------------------------------- General + +#[test] +fn split_layers_go_above_or_below_the_original() { + let mut s = session(); + let a = solid(&mut s, "#ff0000"); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); + let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); + assert_eq!(order, vec![a, below], "off (default): the new part goes below"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": true})).unwrap(); + let above = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap(); + let order: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); + assert_eq!(order, vec![above, a]); +} + +#[test] +fn new_masks_cycle_colours_only_when_asked() { + let mut s = session(); + let a = solid(&mut s, "#ff0000"); + let colors = |s: &Session| -> Vec<[u8; 3]> { + layer(s, a) + .props + .sub("masks") + .unwrap() + .groups() + .map(|g| match g.kind { + effectcraft_project::GroupKind::Mask { color, .. } => color, + _ => [0; 3], + }) + .collect() + }; + s.execute("layer.addMask", json!({"layer": a})).unwrap(); + s.execute("layer.addMask", json!({"layer": a})).unwrap(); + let c = colors(&s); + assert_ne!(c[0], c[1], "cycling by default"); + s.execute("prefs.set", json!({"key": "appearance.cycleMaskColors", "value": false})).unwrap(); + s.execute("layer.addMask", json!({"layer": a})).unwrap(); + s.execute("mask.new", json!({"layer": a, "vertices": [[0, 0], [5, 5]]})).unwrap(); + let c = colors(&s); + assert_eq!(c[2], c[0]); + assert_eq!(c[3], c[0]); +} + +#[test] +fn preserve_constant_vertex_count_edits_every_mask_key() { + let mut s = session(); + let a = solid(&mut s, "#ff0000"); + let m = s.execute("layer.addMask", json!({"layer": a})).unwrap()["mask"].as_u64().unwrap(); + s.execute("prop.select", json!({"layer": a, "prop": layer(&s, a).props.sub("masks").unwrap().groups().next().unwrap().get("path").unwrap().uid})).unwrap(); + let path_uid = layer(&s, a).props.sub("masks").unwrap().groups().next().unwrap().get("path").unwrap().uid; + s.execute("prop.toggleAnimation", json!({"layer": a, "prop": path_uid})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("mask.setVertex", json!({"layer": a, "mask": m, "index": 0, "point": [1, 1]})).unwrap(); + let counts = |s: &Session| -> Vec { + layer(s, a) + .props + .find(path_uid) + .unwrap() + .keys + .iter() + .map(|k| match &k.value { + KV::Path(p) => p.vertices.len(), + _ => 0, + }) + .collect() + }; + assert_eq!(counts(&s), vec![4, 4]); + // On (the default): a vertex added at one key is added to the other. + s.execute("mask.insertVertex", json!({"layer": a, "mask": m, "segment": 0})).unwrap(); + assert_eq!(counts(&s), vec![5, 5]); + s.state.selected_vertices = vec![crate::VertexRef { layer: LayerId(a), mask: m, index: 1 }]; + s.execute("mask.deleteVertices", json!({})).unwrap(); + assert_eq!(counts(&s), vec![4, 4]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(counts(&s), vec![5, 5], "one undo step"); + // Off: only the current key changes. + s.execute("prefs.set", json!({"key": "general.preserveConstantVertexCount", "value": false})).unwrap(); + s.execute("mask.addVertex", json!({"layer": a, "mask": m, "point": [3, 3]})).unwrap(); + assert_eq!(counts(&s), vec![5, 6]); +} + +#[test] +fn switches_affect_nested_comps_passes_quality_down() { + let mut s = Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 40, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let a = solid(&mut s, "#ff8040"); + // A rotated, scaled layer: bilinear (Best) and nearest (Draft) sampling differ. + s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 17})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [131, 131]})).unwrap(); + let outer = s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 40, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let pre = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.quality", json!({"layers": [pre], "quality": "draft"})).unwrap(); + let draw = |s: &Session| s.render(ItemId(outer), Tick::ZERO, RenderOpts::default()); + let on = draw(&s); + s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": false})).unwrap(); + let off = draw(&s); + assert_ne!(on.data, off.data, "the precomp's Draft switch reaches the nested layer only when on"); + // On = the nested layer itself set to Draft. + s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": true})).unwrap(); + s.open_comp(ItemId(inner)); + s.execute("layer.quality", json!({"layers": [a], "quality": "draft"})).unwrap(); + s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": false})).unwrap(); + assert_eq!(draw(&s).data, on.data); +} + +#[test] +fn related_comps_share_the_current_time() { + let mut s = Session::default(); + let inner = s.execute("comp.new", json!({"name": "Inner", "duration": 10, "frameRate": 10})).unwrap()["comp"].as_u64().unwrap(); + let outer = s.execute("comp.new", json!({"name": "Outer", "duration": 10, "frameRate": 10})).unwrap()["comp"].as_u64().unwrap(); + let l = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTiming", json!({"layer": l, "startTime": 2.0})).ok(); + s.edit("t", None, |p, _| { + p.comp_mut(ItemId(outer)).unwrap().layer_mut(LayerId(l)).unwrap().start_time = Tick::from_seconds_f64(2.0); + Ok(()) + }) + .unwrap(); + s.set_time(Tick::from_seconds_f64(5.0)); + assert!((s.time_of(ItemId(inner)).seconds() - 3.0).abs() < 1e-9, "nested comp follows through the layer's start"); + s.open_comp(ItemId(inner)); + s.set_time(Tick::from_seconds_f64(1.0)); + assert!((s.time_of(ItemId(outer)).seconds() - 3.0).abs() < 1e-9, "and the other way"); + s.execute("prefs.set", json!({"key": "general.syncTimeRelatedItems", "value": false})).unwrap(); + s.set_time(Tick::from_seconds_f64(7.0)); + assert!((s.time_of(ItemId(outer)).seconds() - 3.0).abs() < 1e-9); +} + +#[test] +fn pick_whip_writes_match_names_without_compact_english() { + let mut s = session(); + let a = solid(&mut s, "#ff0000"); + let r = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layer": r, "effect": "Gaussian Blur"})).unwrap(); + s.execute("prefs.set", json!({"key": "general.expressionPickWhipCompact", "value": false})).unwrap(); + let e = s.execute("prop.pickWhip", json!({"layer": a, "path": "transform/opacity", "target": {"layer": r, "path": "transform/opacity"}})).unwrap(); + assert_eq!(e, json!("thisComp.layer(\"Other\")(\"transform\")(\"opacity\")")); + let e = s.execute("prop.pickWhip", json!({"layer": a, "path": "transform/rotation", "target": {"layer": a, "path": "transform/opacity"}})).unwrap(); + assert_eq!(e, json!("thisLayer(\"transform\")(\"opacity\")")); +} + +// ---------------------------------------------------------------- pure setting helpers + +#[test] +fn cache_budgets_leave_reserved_ram_and_shrink_when_low() { + use crate::sysinfo::SysMemory; + let mut p = crate::prefs::Prefs::default(); + p.memory.layer_cache_mb = 4096; + p.memory.media_cache_mb = 4096; + p.memory.preview_cache_mb = 8192; + p.memory.ram_reserved_gb = 6; + let b = p.cache_budgets(None); + assert_eq!((b.layer >> 20, b.preview >> 20, b.capped), (4096, 8192, false)); + // 16 GB machine, 6 GB reserved: 16 GB of caches scaled into 10 GB. + let m = SysMemory { total: 16 << 30, available: 12 << 30 }; + let b = p.cache_budgets(Some(m)); + assert!(b.capped && !b.reduced); + assert!(((b.layer + b.media + b.preview) as i64 - (10i64 << 30)).abs() < 4, "{b:?}"); + // Low on memory: halved; off: not. + let low = SysMemory { total: 16 << 30, available: 512 << 20 }; + let r = p.cache_budgets(Some(low)); + assert!(r.reduced && r.preview < b.preview / 2 + 2); + p.memory.reduce_cache_when_low = false; + assert!(!p.cache_budgets(Some(low)).reduced); +} + +#[test] +fn motion_path_span_follows_the_composition_setting() { + let mut p = crate::prefs::Prefs::default(); + let s = |x: f64| Tick::from_seconds_f64(x); + let keys: Vec = (0..40).map(|i| s(i as f64)).collect(); + // Default: no more than 15 keyframes, centred on the current time. + assert_eq!(p.motion_path_span(&keys, s(20.0)), Some((s(13.0), s(27.0)))); + assert_eq!(p.motion_path_span(&keys, s(0.0)), Some((s(0.0), s(14.0)))); + p.composition.motion_path = "all".into(); + assert_eq!(p.motion_path_span(&keys, s(5.0)), Some((s(0.0), s(39.0)))); + p.composition.motion_path = "none".into(); + assert_eq!(p.motion_path_span(&keys, s(5.0)), None); + p.composition.motion_path = "seconds".into(); + p.composition.motion_path_seconds = 4.0; + assert_eq!(p.motion_path_span(&keys, s(10.0)), Some((s(8.0), s(12.0)))); +} + +#[test] +fn import_export_helpers() { + use effectcraft_project::AlphaMode; + let mut p = crate::prefs::Prefs::default(); + assert_eq!(p.unlabeled_alpha(false), None, "Ask User"); + for (v, movie, want) in [ + ("ignore", false, AlphaMode::Ignore), + ("premultiplied", false, AlphaMode::Premultiplied), + ("guess", true, AlphaMode::Premultiplied), + ("guess", false, AlphaMode::Straight), + ] { + p.import.unlabeled_alpha = v.into(); + assert_eq!(p.unlabeled_alpha(movie), Some(want)); + } + assert_eq!(p.drag_import_as(), "footage"); + p.import.drag_import_as = "compLayerSizes".into(); + assert_eq!(p.drag_import_as(), "compositionLayerSizes"); + assert_eq!( + super::commands::missing_frames_for_tests(&["a_001.png".into(), "a_002.png".into(), "a_005.png".into(), "a_007.png".into()]).unwrap(), + "3 missing frames (3–4, 6)" + ); + assert!(super::commands::missing_frames_for_tests(&["a1.png".into(), "a2.png".into()]).is_none()); + // Append Bit Depth to File Name. + assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1.png"); + p.export.append_bits_to_name = true; + assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1_16bpc.png"); + assert_eq!(p.output_name("Comp_[#####].exr", 32), "Comp_[#####]_32bpc.exr"); + // Segments: sequences by file count, movies by size at the data rate. + assert_eq!(p.segment_frames(100, true, 0.0), None); + p.export.segment_sequences = true; + p.export.segment_sequence_files = 30; + assert_eq!(p.segment_frames(100, true, 0.0), Some(30)); + p.export.segment_movies = true; + p.export.segment_movie_mb = 1; + assert_eq!(p.segment_frames(100, false, 1_048_576.0 / 10.0), Some(10)); + assert_eq!(p.segment_frames(5, false, 1_048_576.0 / 10.0), None); + // Recent fonts. + for f in ["A", "B", "C", "A"] { + p.push_recent_font(f); + } + p.type_.recent_fonts = 2; + assert_eq!(p.recent_fonts_shown(), ["A".to_string(), "C".to_string()]); +} + +#[test] +fn render_queue_uses_default_folder_bit_depth_names_and_segments() { + use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem}; + let mut s = session(); + solid(&mut s, "#ff0000"); + let dir = tmp("out"); + s.execute("prefs.set", json!({"values": {"export.defaultOutputFolder": dir.to_string_lossy(), "export.appendBitsToName": true}})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let mut item = RenderQueueItem::new(1, cid); + item.output.output = "Main.png".into(); + item.output.format = OutputFormat::PngSequence; + let out = s.resolve_output(&item).unwrap(); + assert_eq!(PathBuf::from(&out), dir.join("Main.png")); + assert_eq!( + s.prefs.output_name(&out, crate::render_queue::output_bits(OutputFormat::PngSequence, s.project.settings.bit_depth)), + dir.join("Main_8bpc.png").to_string_lossy() + ); + // 20 frames in segments of 8 files: three folders, spans tiling the work area. + s.prefs.export.segment_sequences = true; + s.prefs.export.segment_sequence_files = 8; + let segs = s.segments(&item, &out); + assert_eq!(segs.len(), 3); + let comp = s.active_comp().unwrap().clone(); + let n: Vec = segs.iter().map(|(i, _)| i.settings.frame_count(&comp)).collect(); + assert_eq!(n, vec![8, 8, 4]); + assert!(segs[1].1.contains("Main_002"), "{}", segs[1].1); + assert_eq!(segs[2].0.settings.first_frame(&comp), 16); + // Movies: a ~1 MB file per segment at the H.264 bitrate. + s.prefs.export.segment_movies = true; + s.prefs.export.segment_movie_mb = 1; + item.output.format = OutputFormat::H264; + item.output.bitrate_kbps = 4000; // 50 kB per frame at 10 fps + let segs = s.segments(&item, "/o/Main.mp4"); + assert_eq!(segs.len(), 1, "20 frames × 50 kB fit in 1 MB"); + item.output.bitrate_kbps = 8000; // 100 kB per frame: 10 frames per MB + let segs = s.segments(&item, "/o/Main.mp4"); + assert_eq!(segs.iter().map(|(_, p)| p.as_str()).collect::>(), ["/o/Main_001.mp4", "/o/Main_002.mp4"]); +} + +#[test] +fn every_wired_setting_is_live_and_display_only_ones_are_documented() { + let todo = crate::prefs::todo_keys(); + assert_eq!(todo, ["general.useSystemColorPicker", "composition.hardwareAcceleratePanels", "scripting.enableJsDebugger"]); + let docs = include_str!("../../../docs/preferences.md"); + assert!(docs.contains("### Display-only settings")); + assert!(!docs.contains("Not wired yet")); +} + +// ---------------------------------------------------------------- scripting gate + +#[test] +fn execute_file_needs_the_gate_and_asks_first() { + let mut s = session(); + let f = tmp("run.txt"); + std::fs::write(&f, "x").unwrap(); + let p = json!({"path": f.to_string_lossy()}); + assert!(s.execute("file.executeFile", p.clone()).is_err(), "off by default"); + s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap(); + s.drain_events(); + let r = s.execute("file.executeFile", p.clone()).unwrap(); + assert!(r.get("needsConfirmation").is_some()); + assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "app.confirmExecute"))); + let r = s.execute("file.executeFile", json!({"path": f.to_string_lossy(), "confirmed": true})).unwrap(); + assert!(r.get("opened").is_some()); + assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u.starts_with("file://")))); + s.execute("prefs.set", json!({"key": "scripting.warnExecutingFiles", "value": false})).unwrap(); + assert!(s.execute("file.executeFile", p).unwrap().get("opened").is_some()); +} + +// ---------------------------------------------------------------- Edit ▸ History, recent lists + +#[test] +fn history_submenu_jumps_back_several_steps() { + let mut s = session(); + solid(&mut s, "#ff0000"); + solid(&mut s, "#00ff00"); + solid(&mut s, "#0000ff"); + let list = s.execute("edit.history", json!({})).unwrap(); + assert_eq!(list["undo"][0], json!("New Solid")); + let n = s.history.undo.len(); + let (entries, _) = crate::menus::dynamic(&s, "history", &Default::default()); + assert_eq!(entries.len(), n); + assert_eq!(entries[1].params, json!({"steps": 2})); + s.execute("edit.history", json!({"steps": 2})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(s.history.redo.len(), 2); + s.execute("edit.history", json!({"redo": 2})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), 3); + assert!(s.execute("edit.history", json!({"steps": 999})).is_err()); +} + +#[test] +fn recent_footage_and_presets_are_remembered() { + let mut s = session(); + let a = solid(&mut s, "#ff0000"); + // Imports are remembered even when the build has no decoder (the error comes after). + let _ = s.execute("file.import", json!({"paths": ["/x/clip.mov", "/x/still.png"]})); + assert_eq!(s.prefs.recent_footage, ["/x/still.png", "/x/clip.mov"]); + let (e, _) = crate::menus::dynamic(&s, "recentFootage", &Default::default()); + assert_eq!(e.iter().map(|e| e.label.as_str()).collect::>(), ["still.png", "clip.mov"]); + s.execute("file.clearRecentFootage", json!({})).unwrap(); + assert_eq!(crate::menus::dynamic(&s, "recentFootage", &Default::default()), (vec![], Some("No Recent Footage"))); + // Presets. + let path = tmp("fade.ecpreset"); + s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap(); + s.execute("anim.savePreset", json!({"path": path})).unwrap(); + s.execute("anim.applyPreset", json!({"path": path, "layers": [a]})).unwrap(); + assert_eq!(s.prefs.recent_presets, [path.to_string_lossy().to_string()]); + let (e, _) = crate::menus::dynamic(&s, "recentPresets", &Default::default()); + assert_eq!(e[0].label, "fade"); + s.state.selected_layers = vec![LayerId(a)]; + s.execute("anim.applyRecentPreset", json!({"index": 0})).unwrap(); + s.execute("anim.clearRecentPresets", json!({})).unwrap(); + assert!(s.prefs.recent_presets.is_empty()); + // Recent lists survive a settings round trip and a reset. + s.prefs.push_recent_footage("/y/a.wav"); + let back = crate::prefs::Prefs::from_json(&s.prefs.to_json()); + assert_eq!(back.recent_footage, ["/y/a.wav"]); + s.execute("prefs.reset", json!({})).unwrap(); + assert_eq!(s.prefs.recent_footage, ["/y/a.wav"]); +} + +#[test] +fn save_a_copy_as_xml_opens_again() { + let mut s = session(); + solid(&mut s, "#ff0000"); + let path = tmp("copy.ecprojx"); + s.execute("file.saveCopyAsXml", json!({"path": path.to_string_lossy()})).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.starts_with(" = r["faces"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(faces.len(), 6); + for f in &faces { + let c = s.project.comp(ItemId(*f)).unwrap(); + assert_eq!((c.width, c.height), (64, 64)); + assert!(c.layers.iter().any(|l| l.is_camera())); + assert!(c.layers.iter().any(|l| l.name == aname && l.is_3d()), "the scene's layers"); + let _ = main; + } + let cube = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap(); + assert_eq!((cube.width, cube.height, cube.layers.len()), (192, 128, 6)); + let out = s.project.comp(ItemId(r["output"].as_u64().unwrap())).unwrap(); + assert_eq!((out.width, out.height), (128, 64)); + let fx = out.layers[0].props.sub("effects").unwrap().groups().next().unwrap().clone(); + assert_eq!(fx.match_id, "ec.vr.converter"); + // Every face camera looks a different way. + let mut dirs = vec![]; + for f in &faces { + let c = s.project.comp(ItemId(*f)).unwrap(); + let cam = c.layers.iter().find(|l| l.is_camera()).unwrap(); + dirs.push(format!("{:?}", cam.props.prop("transform/orientation").unwrap().value)); + } + dirs.sort(); + dirs.dedup(); + assert_eq!(dirs.len(), 6, "{dirs:?}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.comp(ItemId(faces[0])).is_none()); + // Extract Cubemap of a 2:1 comp. + let eq = s.execute("comp.new", json!({"name": "Equi", "width": 128, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let r = s.execute("comp.vr.extractCubemap", json!({"comp": eq})).unwrap(); + let c = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap(); + assert_eq!((c.width, c.height), (96, 64)); +} + +/// The equirectangular output sees each wall of a box around the rig in the right direction. +#[test] +fn vr_environment_faces_look_the_right_way() { + // One wall at a time (centre 100,100,0; AE's −Y is up), and where the equirectangular output + // must show it: front +Z, back −Z, right +X, left −X, up, down. + let walls = [ + ("front", [100.0, 100.0, 150.0], [0.0, 0.0], (0.5, 0.5)), + ("back", [100.0, 100.0, -150.0], [0.0, 180.0], (0.01, 0.5)), + ("right", [250.0, 100.0, 0.0], [0.0, 90.0], (0.75, 0.5)), + ("left", [-50.0, 100.0, 0.0], [0.0, 90.0], (0.25, 0.5)), + ("up", [100.0, -50.0, 0.0], [90.0, 0.0], (0.5, 0.03)), + ("down", [100.0, 250.0, 0.0], [90.0, 0.0], (0.5, 0.97)), + ]; + for (name, pos, rot, (fx, fy)) in walls { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Room", "width": 200, "height": 200, "duration": 1, "frameRate": 10})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": pos})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/rotationX", "value": rot[0]})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/rotationY", "value": rot[1]})).unwrap(); + let r = s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap(); + let img = s.render(ItemId(r["output"].as_u64().unwrap()), Tick::ZERO, RenderOpts::default()); + let (w, h) = (img.width as f64, img.height as f64); + let alpha = + |x: f64, y: f64| img.data[((y * h) as usize).min(img.height as usize - 1) * img.width as usize + ((x * w) as usize).min(img.width as usize - 1)][3]; + assert!(alpha(fx, fy) > 0.5, "{name}: the wall shows in its direction"); + // The opposite direction is empty. + let (ox, oy) = if fy != 0.5 { (fx, 1.0 - fy) } else { ((fx + 0.5) % 1.0, fy) }; + assert!(alpha(ox, oy) < 0.5, "{name}: nothing in the opposite direction"); + } +} + +// ---------------------------------------------------------------- keyframes + +#[test] +fn convert_audio_to_keyframes_makes_the_amplitude_null() { + assert_eq!(crate::commands::amplitudes_for_tests(&[0.5, -0.25, -0.5, 0.25]), (50.0, 25.0, 37.5)); + let mut s = session(); + let undo = s.history.undo.len(); + let r = s.execute("keys.audioToKeyframes", json!({})).unwrap(); + assert_eq!(s.history.undo.len(), undo + 1); + let l = layer(&s, r["layer"].as_u64().unwrap()); + assert_eq!(l.name, "Audio Amplitude"); + let fx: Vec<_> = l.props.sub("effects").unwrap().groups().map(|g| (g.name.clone(), g.get("slider").unwrap().keys.len())).collect(); + assert_eq!(fx, [("Left Channel".to_string(), 20), ("Right Channel".to_string(), 20), ("Both Channels".to_string(), 20)]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layers.is_empty()); +} + +#[test] +fn keyframe_labels_and_label_groups() { + let mut s = session(); + let a = solid(&mut s, "#ff0000"); + for t in [0.0, 0.5, 1.0] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": 50})).unwrap(); + } + let uid = layer(&s, a).props.prop("transform/opacity").unwrap().uid; + let k = |t: f64| KeyRef { layer: LayerId(a), prop: uid, time: Tick::from_seconds_f64(t) }; + s.state.selected_keys = vec![k(0.0), k(1.0)]; + // Edit ▸ Label colours the selected keyframes. + s.execute("edit.label", json!({"label": "Blue"})).unwrap(); + let labels: Vec = layer(&s, a).props.prop("transform/opacity").unwrap().keys.iter().map(|k| k.label).collect(); + let blue = effectcraft_color::Label::ALL.iter().position(|l| *l == effectcraft_color::Label::Blue).unwrap() as u8; + assert_eq!(labels, [blue, 0, blue]); + assert_eq!(layer(&s, a).label, effectcraft_color::Label::Red, "the layer keeps its label"); + // Serde: labels round-trip; unlabeled keys don't write the field. + let json = s.project.to_json(); + assert!(json.contains(&format!("\"label\": {blue}")) || json.contains(&format!("\"label\":{blue}"))); + let back = effectcraft_project::Project::from_json(&json).unwrap(); + assert_eq!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[2].label, blue); + // Moving a labelled key's value keeps the label. + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 80})).unwrap(); + assert_eq!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[2].label, blue); + // Select Keyframe Label Group. + s.state.selected_keys = vec![k(0.0)]; + let r = s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap(); + assert_eq!(r["keys"], 2); + s.state.selected_keys = vec![k(0.5)]; + s.execute("keys.selectLabelGroup", json!({"scope": "selected"})).unwrap(); + assert_eq!(s.state.selected_keys, vec![k(0.5)]); + s.state.selected_keys = vec![k(0.0)]; + let r = s.execute("keys.selectLabelGroup", json!({"scope": "visibleAll", "visible": [12345]})).unwrap(); + assert_eq!(r["keys"], 0, "only the given (visible) properties"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys.iter().all(|k| k.label == 0)); +} + +#[test] +fn rpf_camera_import_reads_json_and_csv_camera_data() { + let mut s = session(); + let json_path = tmp("cam.json"); + std::fs::write( + &json_path, + r#"{"frameRate": 10, "frames": [{"frame": 0, "position": [0, 0, -500], "rotation": [0, 0, 0], "fov": 90}, {"frame": 10, "position": [100, 0, -500], "orientation": [0, 30, 0], "zoom": 400}]}"#, + ) + .unwrap(); + let undo = s.history.undo.len(); + let r = s.execute("keys.rpfCameraImport", json!({"path": json_path.to_string_lossy()})).unwrap(); + assert_eq!(s.history.undo.len(), undo + 1); + let cam = layer(&s, r["layer"].as_u64().unwrap()); + assert!(cam.is_camera()); + let pos = &cam.props.prop("transform/position").unwrap().keys; + assert_eq!(pos.len(), 2); + assert_eq!(pos[1].value, KV::Vec3([100.0, 0.0, -500.0])); + assert!((pos[1].time.seconds() - 1.0).abs() < 1e-9); + let zoom = &cam.props.prop("cameraOptions/zoom").unwrap().keys; + assert!((zoom[0].value.as_f64() - 32.0).abs() < 1e-6, "90° across 64 px = zoom 32: {:?}", zoom[0].value); + let csv = tmp("cam.csv"); + std::fs::write(&csv, "frame,px,py,pz,rx,ry,rz,zoom\n0,1,2,3,0,0,0,100\n5,4,5,6,10,20,30,200\n").unwrap(); + let r = s.execute("keys.rpfCameraImport", json!({"path": csv.to_string_lossy()})).unwrap(); + let cam = layer(&s, r["layer"].as_u64().unwrap()); + let o = &cam.props.prop("transform/orientation").unwrap().keys; + assert_eq!(o[1].value, KV::Vec3([10.0, 20.0, 30.0])); + assert!(s.execute("keys.rpfCameraImport", json!({"path": "/x/scene.rpf"})).unwrap_err().to_string().contains("JSON or CSV")); +} + +// ---------------------------------------------------------------- menus + +#[test] +fn dynamic_menus_and_window_viewer_labels() { + let mut s = session(); + s.prefs.push_recent("/p/One.ecproj"); + let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default()); + assert_eq!((e[0].label.as_str(), e[0].command.as_str()), ("One.ecproj", "file.openRecent")); + assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len()); + let a = solid(&mut s, "#ff0000"); + s.state.selected_layers = vec![LayerId(a)]; + let entry = |cmd: &str, panel: &str| crate::menus::MenuEntry { label: "x".into(), command: cmd.into(), params: json!({"panel": panel}), shortcut: None }; + assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "composition")), "Composition: Main"); + assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "timeline")), "Timeline: Main"); + assert!(crate::menus::entry_label(&s, &entry("window.panel", "layer")).starts_with("Layer: ")); + assert_eq!(crate::menus::entry_label(&s, &entry("window.unavailablePanel", "footage")), "Footage: (none)"); + // Other open comps are listed as viewers. + let other = s.execute("comp.new", json!({"name": "Second"})).unwrap()["comp"].as_u64().unwrap(); + let (e, _) = crate::menus::dynamic(&s, "openViewers", &Default::default()); + assert_eq!(e.iter().map(|e| e.label.as_str()).collect::>(), ["Composition: Main"]); + assert_eq!(e[0].command, "comp.open"); + let _ = other; + // Every tree entry of the new menus resolves. + for (path, e) in crate::menus::entries() { + if ["History", "VR", "Recent Animation Presets", "Import Recent Footage"].iter().any(|p| path.contains(&p.to_string())) { + assert!(crate::commands::find(&e.command).is_some()); + } + } + let labels: Vec = crate::menus::entries().iter().map(|(_, e)| e.label.clone()).collect(); + for l in [ + "Save a Copy As XML...", + "With Layered Comp", + "Create VR Environment...", + "Extract Cubemap...", + "System Compatibility Report...", + "Enable Logging", + "Reveal Logging File", + ] { + assert!(labels.iter().any(|x| x == l), "{l}"); + } +} diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 4456a12c..cc085447 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() { let ev = frontend_events(&mut s); assert_eq!(ev[0].0, "view.zoomIn"); assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"}))); - for id in ["keys.audioToKeyframes", "layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] { + for id in ["layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] { assert!(!s.is_enabled(id), "{id}"); assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}"); } diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index 997552bc..58a1b620 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -332,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() { assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id))); } // What remains a stub is still disabled. - for id in ["layer.autoTrace", "keys.audioToKeyframes"] { + for id in ["layer.autoTrace", "layer.sceneEditDetection"] { assert!(!s.is_enabled(id), "{id}"); } } diff --git a/crates/engine/src/xml_project.rs b/crates/engine/src/xml_project.rs new file mode 100644 index 00000000..ae899d46 --- /dev/null +++ b/crates/engine/src/xml_project.rs @@ -0,0 +1,265 @@ +//! File ▸ Save a Copy As XML…: the project as an XML document (`.ecprojx`), the counterpart of +//! After Effects' `.aepx`. It is a lossless, element-per-value encoding of the `.ecproj` JSON, so +//! File ▸ Open reads it back (and saving to a `.ecprojx` path writes XML again): +//! +//! ```xml +//! +//! +//! +//! 3 +//! … +//! +//! +//! ``` +//! +//! `obj`/`m n="key"` (object members), `arr`, `str`, `num`, `bool`, `null`. + +use serde_json::{Map, Value}; + +const ROOT: &str = "EffectCraftProject"; + +/// Whether `text` looks like an XML project. +pub fn is_xml(text: &str) -> bool { + let t = text.trim_start_matches('\u{feff}').trim_start(); + t.starts_with(" bool { + let p = path.to_ascii_lowercase(); + p.ends_with(".ecprojx") || p.ends_with(".xml") +} + +fn esc(s: &str, out: &mut String) { + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\r' => out.push_str(" "), + c if (c as u32) < 0x20 && c != '\n' && c != '\t' => out.push_str(&format!("&#{};", c as u32)), + c => out.push(c), + } + } +} + +fn write(v: &Value, depth: usize, out: &mut String) { + let pad = " ".repeat(depth); + out.push_str(&pad); + match v { + Value::Null => out.push_str(""), + Value::Bool(b) => out.push_str(&format!("{b}")), + Value::Number(n) => out.push_str(&format!("{n}")), + Value::String(s) => { + out.push_str(""); + esc(s, out); + out.push_str(""); + } + Value::Array(a) if a.is_empty() => out.push_str(""), + Value::Array(a) => { + out.push_str("\n"); + for x in a { + write(x, depth + 1, out); + } + out.push_str(&pad); + out.push_str(""); + } + Value::Object(m) if m.is_empty() => out.push_str(""), + Value::Object(m) => { + out.push_str("\n"); + for (k, x) in m { + out.push_str(&" ".repeat(depth + 1)); + out.push_str("\n"); + write(x, depth + 2, out); + out.push_str(&" ".repeat(depth + 1)); + out.push_str("\n"); + } + out.push_str(&pad); + out.push_str(""); + } + } + out.push('\n'); +} + +/// The project JSON as an XML document. +pub fn to_xml(json: &str) -> Result { + let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?; + let mut out = format!("\n<{ROOT} encoding=\"json\" version=\"1\">\n"); + write(&v, 1, &mut out); + out.push_str(&format!("\n")); + Ok(out) +} + +struct P<'a> { + s: &'a str, + i: usize, +} + +impl<'a> P<'a> { + fn err(&self, m: &str) -> Result { + Err(format!("XML project: {m} at byte {}", self.i)) + } + fn rest(&self) -> &'a str { + &self.s[self.i..] + } + /// Skip whitespace, comments and processing instructions. + fn skip(&mut self) { + loop { + let t = self.rest(); + let trimmed = t.trim_start(); + self.i += t.len() - trimmed.len(); + if trimmed.starts_with("").map_or(trimmed.len(), |e| e + 2); + } else if trimmed.starts_with("").map_or(trimmed.len(), |e| e + 3); + } else { + return; + } + } + } + /// An opening tag: (name, attribute `n`, self-closing). + fn open(&mut self) -> Result<(String, Option, bool), String> { + self.skip(); + if !self.rest().starts_with('<') || self.rest().starts_with("').ok_or("XML project: unterminated tag")?; + let inner = &self.rest()[1..end]; + self.i += end + 1; + let (inner, closed) = match inner.strip_suffix('/') { + Some(x) => (x, true), + None => (inner, false), + }; + let mut parts = inner.splitn(2, char::is_whitespace); + let name = parts.next().unwrap_or_default().to_string(); + let attrs = parts.next().unwrap_or(""); + let n = attrs.find("n=\"").map(|a| { + let v = &attrs[a + 3..]; + unesc(&v[..v.find('"').unwrap_or(v.len())]) + }); + Ok((name, n, closed)) + } + fn close(&mut self, name: &str) -> Result<(), String> { + self.skip(); + let want = format!(""); + if self.rest().starts_with(&want) { + self.i += want.len(); + Ok(()) + } else { + self.err(&format!("expected {want}")) + } + } + fn text(&mut self) -> String { + let end = self.rest().find('<').unwrap_or(self.rest().len()); + let t = unesc(&self.rest()[..end]); + self.i += end; + t + } + fn value(&mut self) -> Result { + let (name, _, closed) = self.open()?; + let v = match (name.as_str(), closed) { + ("null", _) => Value::Null, + ("arr", true) => Value::Array(vec![]), + ("obj", true) => Value::Object(Map::new()), + ("str", true) => Value::String(String::new()), + ("bool", false) => Value::Bool(self.text().trim() == "true"), + ("num", false) => serde_json::from_str(self.text().trim()).map_err(|e| format!("XML project: bad number: {e}"))?, + ("str", false) => Value::String(self.text()), + ("arr", false) => { + let mut a = vec![]; + loop { + self.skip(); + if self.rest().starts_with(" { + let mut m = Map::new(); + loop { + self.skip(); + if self.rest().starts_with(""); + } + let v = self.value()?; + self.close("m")?; + m.insert(key.unwrap_or_default(), v); + } + Value::Object(m) + } + (other, _) => return self.err(&format!("unexpected <{other}>")), + }; + if !closed && name != "null" { + self.close(&name)?; + } + Ok(v) + } +} + +fn unesc(s: &str) -> String { + if !s.contains('&') { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(a) = rest.find('&') { + out.push_str(&rest[..a]); + rest = &rest[a..]; + let Some(e) = rest.find(';') else { break }; + let ent = &rest[1..e]; + match ent { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + n if n.starts_with("#x") => out.extend(u32::from_str_radix(&n[2..], 16).ok().and_then(char::from_u32)), + n if n.starts_with('#') => out.extend(n[1..].parse::().ok().and_then(char::from_u32)), + other => { + out.push('&'); + out.push_str(other); + out.push(';'); + } + } + rest = &rest[e + 1..]; + } + out.push_str(rest); + out +} + +/// The project JSON of an XML project document. +pub fn from_xml(text: &str) -> Result { + let mut p = P { s: text.trim_start_matches('\u{feff}'), i: 0 }; + let (root, _, _) = p.open()?; + if root != ROOT { + return Err(format!("not an EffectCraft XML project (root <{root}>)")); + } + let v = p.value()?; + p.close(ROOT)?; + serde_json::to_string(&v).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_round_trips_through_xml() { + let j = serde_json::json!({"a": [1, 2.5, -3e-7, true, null, "x")); + let back: Value = serde_json::from_str(&from_xml(&xml).unwrap()).unwrap(); + assert_eq!(back, j); + assert!(from_xml("").is_err()); + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 941a7723..1f0c4407 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -69,6 +69,8 @@ pub struct Job<'a> { pub path: &'a str, /// Hand finished files to this instead of writing them to disk (web downloads, tests). pub sink: Option<&'a Sink>, + /// Settings ▸ General ▸ Switches Affect Nested Comps (see `RenderOpts::nested_switches`). + pub nested_switches: bool, } /// Progress after a batch of frames. @@ -182,6 +184,8 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image { view: None, backend: effectcraft_render::Backend::Auto, roi: None, + nested_switches: job.nested_switches, + draft_shadows: true, proxy: job.settings.proxy_use, }; let mut r = Renderer::new(job.project, job.footage, opts); diff --git a/crates/export/tests/roundtrip.rs b/crates/export/tests/roundtrip.rs index 1ade594f..f8bef9f2 100644 --- a/crates/export/tests/roundtrip.rs +++ b/crates/export/tests/roundtrip.rs @@ -55,7 +55,7 @@ fn settings() -> RenderSettings { fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report { let s = settings(); let path = path.to_string_lossy().to_string(); - let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None }; + let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None, nested_switches: true }; let mut last = Progress::default(); let mut calls = 0; let r = export(&job, &mut |pr| { @@ -167,7 +167,8 @@ fn h264_odd_size_is_cropped_even() { let comp = p.comp(cid).expect("comp"); assert_eq!(s.output_size(comp).0 % 2, 1); let path = d.join("odd.mp4").to_string_lossy().to_string(); - let job = Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None }; + let job = + Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None, nested_switches: true }; let r = export(&job, &mut |_| true).expect("export"); assert_eq!((r.width % 2, r.height % 2), (0, 0)); } @@ -287,7 +288,8 @@ fn cancel_stops_early() { let om = OutputModule::for_format(OutputFormat::PngSequence); let s = settings(); let path = d.join("c_[#####].png").to_string_lossy().to_string(); - let job = Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None }; + let job = + Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None, nested_switches: true }; let r = export(&job, &mut |_| false); assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled))); let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0); diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index b5de7714..cfa5f825 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -40,6 +40,7 @@ impl Exporter for FileExporter { output: &job.item.output, path: job.path, sink: self.sink.as_deref(), + nested_switches: job.nested_switches, }; match effectcraft_export::export(&j, &mut |p| progress(p.done, p.total)) { Ok(r) => Ok(ExportResult { path: r.path, frames: r.frames, width: r.width, height: r.height, bytes: r.bytes, seconds: r.seconds, audio: r.audio }), diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index ec2b4754..2bc0258e 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -520,6 +520,7 @@ pub fn set_key(keys: &mut Vec, key: Keyframe) -> usize { k.spatial_in = old.spatial_in; k.spatial_out = old.spatial_out; k.spatial_auto = old.spatial_auto; + k.label = old.label; keys[i] = k; i } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9bdefcb6..a4c7300d 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -131,6 +131,12 @@ struct Inner { hits: AtomicU64, misses: AtomicU64, prefetched: AtomicU64, + /// Settings ▸ Disk ▸ Conformed Audio Folder: decoded audio tracks written once per file and + /// rate, read back instead of decoding again (`None` = off). + conform: Mutex>, + /// Conformed files being written right now. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + conforming: Mutex>, } /// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus @@ -193,6 +199,8 @@ impl MediaPool { hits: AtomicU64::new(0), misses: AtomicU64::new(0), prefetched: AtomicU64::new(0), + conform: Mutex::default(), + conforming: Mutex::default(), }), } } @@ -257,14 +265,108 @@ impl MediaPool { Inner::frame_at(&self.inner, footage, t, true) } + /// Settings ▸ Disk ▸ Conformed Audio Folder: write each footage file's decoded audio there + /// once per sample rate (raw interleaved stereo `f32`) and read it back instead of decoding + /// again. `None` turns it off. + pub fn set_conform_folder(&self, folder: Option) { + *lock(&self.inner.conform) = folder; + } + + /// The conformed-audio file of `path` at `rate` (named by a hash of the path, size and + /// modification time, so an edited file conforms again). + pub fn conformed_path(&self, path: &str, rate: u32) -> Option { + use std::hash::{Hash, Hasher}; + let folder = lock(&self.inner.conform).clone()?; + let meta = std::fs::metadata(path).ok()?; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (path, meta.len(), meta.modified().ok()).hash(&mut h); + Some(folder.join(format!("{:016x}_{rate}.ecaf", h.finish()))) + } + + /// Samples from a conformed file: `Some` when it exists and covers the request. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + fn read_conformed(file: &std::path::Path, s0: usize, frames: usize) -> Option> { + use std::io::{Read, Seek, SeekFrom}; + let mut f = std::fs::File::open(file).ok()?; + let len = f.metadata().ok()?.len() as usize / 8; + let mut out = vec![0.0f32; frames * 2]; + if s0 >= len { + return Some(out); + } + let n = frames.min(len - s0); + f.seek(SeekFrom::Start(s0 as u64 * 8)).ok()?; + let mut bytes = vec![0u8; n * 8]; + f.read_exact(&mut bytes).ok()?; + for (o, c) in out.iter_mut().zip(bytes.chunks_exact(4)) { + *o = f32::from_le_bytes([c[0], c[1], c[2], c[3]]); + } + Some(out) + } + + /// Conform `footage`'s audio at `rate` into `file` on a worker thread (once). + #[cfg(not(target_arch = "wasm32"))] + fn conform(&self, footage: &Footage, file: std::path::PathBuf, rate: u32) { + if !lock(&self.inner.conforming).insert(file.clone()) { + return; + } + let (pool, f) = (self.clone(), footage.clone()); + let spawned = std::thread::Builder::new().name("ec-conform-audio".into()).spawn(move || { + let frames = f.duration.to_units_floor(rate as i64).max(0) as usize; + let mut bytes = Vec::with_capacity(frames * 8); + let mut at = 0usize; + while at < frames { + let n = (frames - at).min(1 << 16); + let t = Tick(at as i64 * TICKS_PER_SECOND / rate as i64); + for v in pool.decode_audio(&f, t, n, rate) { + bytes.extend_from_slice(&v.to_le_bytes()); + } + at += n; + } + let ok = file.parent().is_some_and(|d| std::fs::create_dir_all(d).is_ok()) && { + let tmp = file.with_extension("tmp"); + std::fs::write(&tmp, &bytes).is_ok() && std::fs::rename(&tmp, &file).is_ok() + }; + if !ok { + log::warn!("media: could not write conformed audio {}", file.display()); + } + lock(&pool.inner.conforming).remove(&file); + }); + if spawned.is_err() { + lock(&self.inner.conforming).clear(); + } + } + /// `frames` stereo sample frames of `footage`'s audio starting at source time `start`, at /// `rate` Hz, interleaved (L R L R …). Mono is duplicated to both channels; channels beyond /// the first two are dropped. Silence where there is no audio (or before the media starts). + /// With a conformed audio folder, reads come from the conformed file once it is written. pub fn audio_samples(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec { - let mut out = vec![0.0; frames * 2]; if !footage.has_audio || footage.missing || rate == 0 || frames == 0 { - return out; + return vec![0.0; frames * 2]; } + #[cfg(not(target_arch = "wasm32"))] + if let Some(file) = self.conformed_path(&footage.path, rate) { + let s0 = start.to_units_floor(rate as i64); + if file.exists() && !lock(&self.inner.conforming).contains(&file) { + let skip = (-s0).max(0) as usize; + if skip >= frames { + return vec![0.0; frames * 2]; + } + if let Some(v) = Self::read_conformed(&file, s0.max(0) as usize, frames - skip) { + let mut out = vec![0.0; skip * 2]; + out.extend(v); + return out; + } + } else { + self.conform(footage, file, rate); + } + } + self.decode_audio(footage, start, frames, rate) + } + + /// [`MediaPool::audio_samples`] straight from the decoder. + fn decode_audio(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec { + let mut out = vec![0.0; frames * 2]; let path: Arc = footage.path.as_str().into(); let Some(src) = self.inner.source(&path) else { return out }; let s0 = start.to_units_floor(rate as i64); @@ -486,6 +588,9 @@ impl Inner { } impl FootageSource for MediaPool { + fn set_conform_folder(&self, folder: Option) { + MediaPool::set_conform_folder(self, folder); + } fn model(&self, _item: ItemId, footage: &Footage) -> Option> { if footage.missing || footage.kind != FootageKind::Model { return None; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index fd9be466..2aedf5ea 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -53,6 +53,9 @@ pub trait FootageSource: Send + Sync { /// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without /// a cache ignore it. fn set_cache_budget(&self, _bytes: usize) {} + /// Settings ▸ Disk ▸ Conformed Audio Folder: where decoded audio is kept between reads + /// (`None` = off); sources that don't decode audio ignore it. + fn set_conform_folder(&self, _folder: Option) {} /// The decoded-frame cache budget in bytes, if the source has a cache. fn cache_budget(&self) -> Option { None @@ -303,6 +306,19 @@ pub struct RenderOpts { pub roi: Option<[f64; 4]>, /// Which proxies stand in for footage and compositions (Render Settings ▸ Proxy Use). pub proxy: effectcraft_project::render_queue::ProxyUse, + /// Switches Affect Nested Comps (Settings ▸ General): a precomp layer's Quality and Motion + /// Blur switches also limit the layers of the nested comp (Draft/Wireframe or motion blur off + /// propagate down; they never raise a nested layer's own setting). + pub nested_switches: bool, + /// Realtime Shadows in Draft (Settings ▸ 3D): draft renders (`draft`) still cast shadows. + pub draft_shadows: bool, +} + +impl RenderOpts { + /// Whether 3D layers cast shadows in this render. + pub fn shadows(&self) -> bool { + !self.draft || self.draft_shadows + } } impl Default for RenderOpts { @@ -315,6 +331,8 @@ impl Default for RenderOpts { view: None, roi: None, backend: Backend::Cpu, + nested_switches: true, + draft_shadows: true, proxy: effectcraft_project::render_queue::ProxyUse::CurrentSettings, } } @@ -342,6 +360,9 @@ pub struct Renderer<'a> { opacity_mul: f32, /// Collapsed precomp: nested 3D layers join the parent's 3D space. pub(crate) collapse3d: Option>, + /// Switches inherited from the precomp layers this comp is rendered through (see + /// [`RenderOpts::nested_switches`]): the worst quality and whether motion blur is allowed. + pub(crate) inherited: Option<(Quality, bool)>, } /// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`). @@ -385,9 +406,39 @@ impl<'a> Renderer<'a> { outer: None, opacity_mul: 1.0, collapse3d: None, + inherited: None, } } + /// A layer's Quality switch, limited by the precomp layers above it when + /// [`RenderOpts::nested_switches`] is on. + pub fn quality(&self, layer: &Layer) -> Quality { + let rank = |q: Quality| match q { + Quality::Best => 0, + Quality::Draft => 1, + Quality::Wireframe => 2, + }; + match self.inherited { + Some((q, _)) if rank(q) > rank(layer.switches.quality) => q, + _ => layer.switches.quality, + } + } + + /// A layer's Motion Blur switch, limited by the precomp layers above it. + pub(crate) fn layer_motion_blur(&self, layer: &Layer) -> bool { + layer.switches.motion_blur && self.inherited.is_none_or(|(_, mb)| mb) + } + + /// The renderer for the nested comp of precomp layer `layer`: its switches pass down when + /// [`RenderOpts::nested_switches`] is on. + fn nested_through(&self, layer: &Layer) -> Renderer<'a> { + let mut sub = self.nested(); + if self.opts.nested_switches { + sub.inherited = Some((self.quality(layer), self.layer_motion_blur(layer))); + } + sub + } + /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, ..*self } @@ -769,7 +820,7 @@ impl<'a> Renderer<'a> { // values. let ov = essential_overrides(ctx, layer); let tmp = if ov.is_empty() { None } else { effectcraft_project::essential::with_overrides(self.project, *item, &ov) }; - let base = self.nested(); + let base = self.nested_through(layer); let sub = match &tmp { Some(p) => Renderer { project: p, ..base }, None => base, @@ -1058,7 +1109,7 @@ impl<'a> Renderer<'a> { } fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling { - if layer.switches.quality == Quality::Draft { + if self.quality(layer) == Quality::Draft { // Draft quality: no interpolation (nearest neighbour), as After Effects' Draft. effectcraft_raster::Sampling::Nearest } else if self.opts.draft { @@ -1103,7 +1154,7 @@ impl<'a> Renderer<'a> { /// Whether a layer is motion blurred. fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool { - self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur + self.opts.motion_blur && ctx.comp.enable_motion_blur && self.layer_motion_blur(layer) } /// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D @@ -1227,7 +1278,7 @@ impl<'a> Renderer<'a> { /// Draw a 2D layer into `canvas`; returns the region it may have changed. fn draw_layer(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, blank: bool) -> Region { let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; - if layer.switches.quality == Quality::Wireframe && !layer.switches.adjustment { + if self.quality(layer) == Quality::Wireframe && !layer.switches.adjustment { self.draw_wireframe(ctx, layer, canvas); return Region::Full; } diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 2c3b03bc..ca124a41 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -855,7 +855,14 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: { add_environment(&mut b, w, h, d, k, rot); } - super::raster::shadow_maps(&mut b.s, r.opts.draft); + if r.opts.shadows() { + super::raster::shadow_maps(&mut b.s, r.opts.draft); + } else { + // Settings ▸ 3D ▸ Realtime Shadows in Draft off: draft renders skip shadows. + for l in &mut b.s.lights { + l.shadow = -1; + } + } } // Camera matrices. let s = r.opts.scale; diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index b134c88f..c2dd4ec0 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -163,7 +163,7 @@ pub(crate) fn camera_for(r: &Renderer, ctx: &EvalCtx) -> CameraState { /// Number of motion-blur sub-samples for a layer. fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { - if r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur { + if r.opts.motion_blur && ctx.comp.enable_motion_blur && r.layer_motion_blur(layer) { if r.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize } } else { 1 @@ -366,9 +366,9 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut } let out = (canvas.width, canvas.height); let g = gather(r, ctx, run, out); - let casters: Vec<&Item> = g.items.iter().chain(g.extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); + let casters: Vec<&Item> = g.items.iter().chain(g.extra.iter()).filter(|i| r.opts.shadows() && i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); composite(canvas, &g.items, &g.lights, &casters, run[0].id.0 as u32); - for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) { + for l in run.iter().filter(|l| r.quality(l) == effectcraft_project::Quality::Wireframe) { r.draw_layer(ctx, l, canvas, false); } } @@ -387,7 +387,7 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, let mut items: Vec = run .par_iter() .enumerate() - .filter(|(_, l)| ctx.opacity(l) > 0.0 && l.switches.quality != effectcraft_project::Quality::Wireframe) + .filter(|(_, l)| ctx.opacity(l) > 0.0 && r.quality(l) != effectcraft_project::Quality::Wireframe) .flat_map_iter(|(i, l)| { // Stack order with room for a collapsed precomp's layers in between. let i = i * 1024; @@ -682,7 +682,7 @@ pub struct Plane3d { /// Prepare a run of 3D layers for an accelerator. `None` when the run must be drawn on the CPU /// (adjustment, wireframe or environment background layers, Advanced 3D). pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Option { - if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || l.switches.quality == effectcraft_project::Quality::Wireframe) + if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || r.quality(l) == effectcraft_project::Quality::Wireframe) { return None; } @@ -711,7 +711,7 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3 order: it.order, }) .collect(); - let casters = planes.iter().enumerate().filter(|(_, p)| p.mat.casts_shadows != 0 && !p.geos.is_empty()).map(|(i, _)| i).collect(); + let casters = planes.iter().enumerate().filter(|(_, p)| r.opts.shadows() && p.mat.casts_shadows != 0 && !p.geos.is_empty()).map(|(i, _)| i).collect(); Some(Run3d { planes, casters, lights: g.lights, seed: run[0].id.0 as u32 }) } diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index c4f256f4..fec9d9e3 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -204,7 +204,7 @@ File.prototype = { return true; }, rename: function () { throw __err("File.rename is not supported"); }, - execute: function () { throw __err("scripts can't execute files"); }, + execute: function () { return JSON.parse(__file("execute", this.__path)); }, openDlg: function () { return null; }, saveDlg: function () { return null; }, }; diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 61b49595..c494a6bf 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -294,6 +294,11 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult 2 { arg_string(args, 2, ctx)? } else { String::new() }; let r: Result = with_active(|a| { + // File.execute(): opened with its default application through `file.executeFile` + // (Allow Scripts to Write Files gate, Warn User When Executing Files confirmation). + if op == "execute" { + return exec(&mut a.session, "file.executeFile", json!({"path": path})).map(|v| json!(v.get("opened").is_some())); + } let s = &a.session; match op.as_str() { "access" => Ok(json!(check_access(s, &path, false)?.exists())), diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index c3a60f5d..009f2522 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -48,6 +48,8 @@ pub struct FaceInfo { pub data: FaceData, /// "bundled", "system", … pub origin: &'static str, + /// The family name in the font's own (non-English) language, when it has one. + pub native_family: Option, } /// Something that can list fonts (system folders, a web font picker, a project's font folder…). @@ -121,7 +123,7 @@ impl FontSource for DirectorySource { } fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo { - FaceInfo { family: n.family, style: n.style, weight: n.weight, italic: n.italic, index: n.index, data, origin } + FaceInfo { family: n.family, style: n.style, weight: n.weight, italic: n.italic, index: n.index, data, origin, native_family: n.native_family } } /// A loaded (or loadable) face. @@ -300,6 +302,12 @@ pub fn all_faces() -> Vec> { db().read().unwrap_or_else(|e| e.into_inner()).faces.clone() } +/// Family names in the fonts' own languages: English family → native family (only families +/// that have one). +pub fn native_families() -> std::collections::BTreeMap { + all_faces().into_iter().filter_map(|f| Some((f.info.family.clone(), f.info.native_family.clone()?))).collect() +} + /// Families with their style names, sorted by family; styles in weight order. pub fn families() -> Vec<(String, Vec)> { let mut m: std::collections::BTreeMap> = Default::default(); diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs index bb00d515..72e34506 100644 --- a/crates/text/src/sfnt.rs +++ b/crates/text/src/sfnt.rs @@ -13,6 +13,9 @@ pub struct FaceNames { pub full_name: String, pub weight: u16, pub italic: bool, + /// The family name in another language than English (CJK, Arabic… fonts), when the font + /// has one (Settings ▸ Type ▸ Show Font Names in English off shows it). + pub native_family: Option, } fn be16(b: &[u8], o: usize) -> Option { @@ -75,6 +78,7 @@ fn read_face(r: &mut R, off: u64, index: u32) -> Option(r: &mut R, off: u64, index: u32) -> Option Option { + let count = be16(b, 2)? as usize; + let storage = be16(b, 4)? as usize; + for i in 0..count { + let r = 6 + i * 12; + let (plat, enc, lang, nid, len, off) = + (be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize); + if nid != id || plat != 3 || !(enc == 1 || enc == 10) || lang == 0x409 { + continue; + } + let raw = b.get(storage + off..storage + off + len)?; + let u: Vec = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + let s = String::from_utf16_lossy(&u); + if !s.trim().is_empty() { + return Some(s); + } + } + None } /// A name-table string by id, preferring Windows Unicode English, then any Unicode, then Mac Roman. diff --git a/crates/ui-egui/src/audio.rs b/crates/ui-egui/src/audio.rs index 3d1d627c..7e24adfe 100644 --- a/crates/ui-egui/src/audio.rs +++ b/crates/ui-egui/src/audio.rs @@ -51,11 +51,13 @@ pub struct AudioOutput { pub device: String, pub left: u32, pub right: u32, + /// Preview Sample Rate: devices that support it are opened at this rate. + pub rate: u32, } impl AudioOutput { pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> AudioOutput { - AudioOutput { device: p.audio.output_device.clone(), left: p.audio.output_left, right: p.audio.output_right } + AudioOutput { device: p.audio.output_device.clone(), left: p.audio.output_left, right: p.audio.output_right, rate: p.audio.preview_sample_rate } } } @@ -165,7 +167,7 @@ pub struct AudioPlayback { stop: Arc, thread: Option>, /// wasm32 (no threads): what the feeder thread would own, run by [`AudioPlayback::pump`]. - feeder: Option<(RenderSource, ItemId, i64)>, + feeder: Option<(RenderSource, ItemId, i64, u32)>, pub rate: u32, pub span: PlaySpan, } @@ -178,6 +180,9 @@ fn queue_target(rate: u32) -> usize { impl AudioPlayback { /// Start mixing `comp` over `span` into `device`. + /// `mix_rate`: Settings ▸ Audio ▸ Preview Sample Rate, the rate the comp's audio is mixed + /// at (resampled to the device's rate when they differ; 0 = the device's rate). + #[allow(clippy::too_many_arguments)] pub fn start( mut device: Box, src: RenderSource, @@ -186,8 +191,10 @@ impl AudioPlayback { wa: Tick, wb: Tick, looping: bool, + mix_rate: u32, ) -> Result { let rate = device.sample_rate().max(8000); + let mix = if mix_rate == 0 { rate } else { mix_rate.clamp(8000, 192_000) }; let span = PlaySpan::new(start, wa, wb, looping, rate); let feed = Arc::new(AudioFeed::default()); let stop = Arc::new(AtomicBool::new(false)); @@ -195,10 +202,10 @@ impl AudioPlayback { let mut cursor = span.start; let prime = (rate as usize / 20).div_ceil(BLOCK); for _ in 0..prime { - cursor = feed_block(&src, comp, &feed, &span, cursor, rate); + cursor = feed_block(&src, comp, &feed, &span, cursor, rate, mix); } if cfg!(target_arch = "wasm32") { - let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, feeder: Some((src, comp, cursor)), rate, span }; + let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, feeder: Some((src, comp, cursor, mix)), rate, span }; p.device.start(feed)?; p.pump(); return Ok(p); @@ -211,7 +218,7 @@ impl AudioPlayback { let target = queue_target(rate); while !stop.load(Ordering::Relaxed) { if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) { - cursor = feed_block(&src, comp, &feed, &span, cursor, rate); + cursor = feed_block(&src, comp, &feed, &span, cursor, rate, mix); } else { std::thread::sleep(std::time::Duration::from_millis(4)); } @@ -226,10 +233,10 @@ impl AudioPlayback { /// Every UI frame while playing: on wasm32 mix ahead into the feed (what the feeder thread /// does elsewhere), then let the device take what it needs. pub fn pump(&mut self) { - if let Some((src, comp, cursor)) = &mut self.feeder { + if let Some((src, comp, cursor, mix)) = &mut self.feeder { let target = queue_target(self.rate); while self.feed.queued_frames() < target && !self.feed.finished.load(Ordering::Relaxed) { - *cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate); + *cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate, *mix); } } self.device.pump(); @@ -261,7 +268,7 @@ impl Drop for AudioPlayback { /// Mix one block at `cursor` into the feed; returns the next cursor (wrapping or finishing at /// the work-area end). -fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, cursor: i64, rate: u32) -> i64 { +fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpan, cursor: i64, rate: u32, mix: u32) -> i64 { let mut cursor = cursor; if cursor >= span.wb { if !span.looping { @@ -271,11 +278,39 @@ fn feed_block(src: &RenderSource, comp: ItemId, feed: &AudioFeed, span: &PlaySpa cursor = span.wa; } let n = ((span.wb - cursor) as usize).min(BLOCK); - let buf = effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, sample_time(cursor, rate), n, rate); + let t = sample_time(cursor, rate); + let buf = if mix == rate { + effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, n, rate) + } else { + let m = (n as u64 * mix as u64).div_ceil(rate as u64) as usize + 1; + let b = effectcraft_engine::render::audio::mix_comp(&src.project, src.footage.as_ref(), src.expr.as_deref(), comp, t, m, mix); + resample_stereo(&b, mix, rate, n) + }; feed.push(&buf); cursor + n as i64 } +/// Linear resampling of interleaved stereo from `from` Hz to `n` frames at `to` Hz. +pub fn resample_stereo(src: &[f32], from: u32, to: u32, n: usize) -> Vec { + let frames = src.len() / 2; + let mut out = Vec::with_capacity(n * 2); + if frames == 0 { + out.resize(n * 2, 0.0); + return out; + } + let step = from as f64 / to as f64; + for i in 0..n { + let x = i as f64 * step; + let a = (x.floor() as usize).min(frames - 1); + let b = (a + 1).min(frames - 1); + let f = (x - a as f64) as f32; + for c in 0..2 { + out.push(src[a * 2 + c] * (1.0 - f) + src[b * 2 + c] * f); + } + } + out +} + /// VU meter state for the Audio panel: smoothed level with a falling decay and peak hold, in /// dBFS per channel. #[derive(Clone, Copy, Debug)] diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 91d7c335..8edf57f4 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -779,6 +779,10 @@ pub fn draw_group_chrome( } } else { let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h)); + if t.gradients { + // Settings ▸ Appearance ▸ Use Gradients. + crate::theme::gradient_rect(&painter, strip.shrink2(vec2(t.radius, 0.0)), t.grad_top(t.panel_bg), t.panel_bg); + } let mut x = strip.min.x + 12.0; let text_y = strip.min.y + 16.0; for (i, p) in g.panels.iter().enumerate() { diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 44900918..3b222570 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -29,6 +29,30 @@ impl EffectcraftApp { out } + /// Settings ▸ Appearance ▸ Use Label Color for Related Tabs: the Composition and Timeline + /// tabs carry their comp's label colour, Effect Controls and Properties the layer's. + fn label_tab_marks(&self, ui: &egui::Ui) { + if !self.session.prefs.appearance.use_label_color_for_tabs { + return; + } + let Some(cid) = self.session.active_comp_id() else { return }; + let comp_label = self.session.project.item(cid).map(|i| i.label); + let layer_label = self.session.active_comp().and_then(|c| self.session.state.selected_layers.first().and_then(|l| c.layer(*l))).map(|l| l.label); + for (p, label) in [ + (PanelKind::Composition, comp_label), + (PanelKind::Timeline, comp_label), + (PanelKind::EffectControls, layer_label), + (PanelKind::Properties, layer_label), + ] { + let (Some(label), Some(e)) = (label, self.auto.find(&format!("panel.tab.{}", p.id()))) else { continue }; + if label == effectcraft_engine::color::Label::None { + continue; + } + let r = Rect::from_min_size(pos2(e.rect[0] + 2.0, e.rect[1] + 9.0), vec2(4.0, (e.rect[3] - 16.0).max(6.0))); + ui.painter().rect_filled(r, 1.0, self.tokens.label(label)); + } + } + /// Edit the full layout (docked tree + floating groups) with a [`Layout`] operation. pub fn edit_layout(&mut self, f: impl FnOnce(&mut Layout) -> bool) -> bool { let mut l = Layout { root: self.ui.dock.clone(), floating: self.ui.floating.clone() }; @@ -98,6 +122,7 @@ impl EffectcraftApp { for g in &groups { actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title)); } + self.label_tab_marks(ui); if maximized.is_none() { self.ui.dock = dock; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 912f2e68..0a5e6276 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod icons; pub mod menus; pub mod native_menu; pub mod panels; +pub mod prefs_live; pub mod state; pub mod theme; pub mod widgets; @@ -255,7 +256,7 @@ impl EffectcraftApp { ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY }); self.ui.cache_when_idle = p.previews.cache_frames_when_idle; self.ui.viewer.fast_preview = p.previews.fast_previews; - self.frames.set_budget(p.preview_cache_bytes()); + self.frames.set_budget(p.cache_budgets(self.session.sys_memory).preview); } /// Change settings from the UI (applied next frame and saved). @@ -475,6 +476,8 @@ impl EffectcraftApp { view: self.session.view_camera(comp), roi, backend: effectcraft_engine::render::Backend::Auto, + nested_switches: self.session.prefs.general.switches_affect_nested_comps, + draft_shadows: self.session.prefs.three_d.realtime_shadows, proxy: Default::default(), } } @@ -528,7 +531,8 @@ impl EffectcraftApp { } let out = audio::AudioOutput::from_prefs(&self.session.prefs); let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return }; - match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop) { + let mix = self.session.prefs.audio.preview_sample_rate; + match audio::AudioPlayback::start(dev, self.render_source(), cid, t, c.work_area.0, c.work_area.1, self.ui.preview_loop, mix) { Ok(a) => self.audio = Some(a), Err(e) => log::warn!("audio preview: {e}"), } @@ -806,6 +810,7 @@ impl EffectcraftApp { self.apply_prefs(&ctx); self.handle_events(&ctx); self.tick_autosave(&ctx); + prefs_live::frame(self, &ctx); if let Some(rx) = self.command_inbox.take() { while let Ok(id) = rx.try_recv() { if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index eed7e639..69c02433 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -754,6 +754,37 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.show_panel(PanelKind::Layer); Value::Null } + "app.confirmExecute" => { + // Settings ▸ Scripting ▸ Warn User When Executing Files. + let path = p.get("path").and_then(Value::as_str).unwrap_or_default().to_string(); + crate::panels::forms::form( + app, + "A script wants to open a file", + "file.executeFile", + json!({"path": path, "confirmed": true}), + vec![crate::panels::forms::Field::text("path", "Open", &path)], + ); + Value::Null + } + "app.showReport" => { + let title = p.get("title").and_then(Value::as_str).unwrap_or("Report").to_string(); + let text = p.get("text").and_then(Value::as_str).unwrap_or_default().to_string(); + crate::panels::dialogs::info(app, &title, &text); + Value::Null + } + "window.assignWorkspaceShortcut" => { + let mut q = p.clone(); + q["workspace"] = json!(app.ui.workspace); + return run_engine(app, ctx, "window.assignWorkspaceShortcut", q); + } + "file.interpretFootage" => { + // Import ▸ Interpret Unlabeled Alpha As: Ask User. + if let Some(i) = p.get("item").and_then(Value::as_u64) { + app.session.state.project_selection = vec![effectcraft_engine::project::ItemId(i)]; + } + crate::panels::dialogs::open_form(app, "file.interpretFootage", &json!({})); + Value::Null + } "effect.manage" | "anim.browsePresets" => { app.show_panel(PanelKind::EffectsPresets); Value::Null @@ -794,6 +825,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])), "file.replaceFootage" => ("path", Ask::Import), "file.collectFiles" => ("folder", Ask::Save("Collected Files")), + "file.saveCopyAsXml" => ("path", Ask::Save("Untitled Project.ecprojx")), + "keys.rpfCameraImport" => ("path", Ask::Open(&["json", "csv", "txt"])), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), "essential.importTemplate" => ("path", Ask::Open(&["ectemplate"])), "file.setProxy" => ("path", Ask::Open(&["mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"])), @@ -873,6 +906,15 @@ pub(crate) fn entry_label(app: &EffectcraftApp, e: &MenuEntry) -> String { match e.command.as_str() { "edit.undo" => app.session.history.undo.last().map(|u| format!("Undo {}", u.0)).unwrap_or_else(|| "Can't Undo".into()), "edit.redo" => app.session.history.redo.last().map(|u| format!("Redo {}", u.0)).unwrap_or_else(|| "Can't Redo".into()), + // Window ▸ Layer: the layer open in the Layer panel. + "window.panel" if e.params.get("panel").and_then(Value::as_str) == Some("layer") => { + let name = app + .ui + .layer_panel + .and_then(|id| app.session.active_comp().and_then(|c| c.layer(effectcraft_engine::project::LayerId(id)))) + .map(|l| l.name.clone()); + format!("Layer: {}", name.unwrap_or_else(|| "(none)".into())) + } _ => effectcraft_engine::menus::entry_label(&app.session, e), } } @@ -1137,29 +1179,38 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c MenuNode::Separator => { ui.separator(); } - MenuNode::Submenu { label, children } if label == "Open Recent" => { - // File ▸ Open Recent: the recent projects (Settings), then the static entries. - ui.menu_button((gutter(false), label.as_str()), |ui| { - ui.set_min_width(320.0); - let recent = app.session.prefs.recent_projects.clone(); - if recent.is_empty() { - ui.add_enabled(false, egui::Button::new((gutter(false), "No Recent Projects"))); + MenuNode::Dynamic { name } => { + // Recent projects / footage / presets, undo history, shortcut slots, viewers. + let ws = app.ui.workspace.clone(); + let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &dyn_ctx(&ws)); + if entries.is_empty() + && let Some(e) = empty + { + ui.add_enabled(false, egui::Button::new((gutter(false), e))); + } + for (i, e) in entries.iter().enumerate() { + let tip = match name.as_str() { + "recentProjects" => app.session.prefs.recent_projects.get(i).cloned(), + "recentFootage" => app.session.prefs.recent_footage.get(i).cloned(), + "recentPresets" => app.session.prefs.recent_presets.get(i).cloned(), + _ => None, + }; + let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(false), e.label.as_str()))); + let r = match &tip { + Some(t) => r.on_hover_text(t), + None => r, + }; + app.auto.add(&format!("menu.{name}.{i}"), r.rect, &e.label); + if r.clicked() { + *clicked = Some((e.command.clone(), e.params.clone())); + ui.close(); } - for (i, path) in recent.iter().enumerate() { - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); - let r = ui.add(egui::Button::new((gutter(false), name))).on_hover_text(path); - app.auto.add(&format!("menu.openRecent.{i}"), r.rect, path); - if r.clicked() { - *clicked = Some(("file.openRecent".into(), json!({"index": i}))); - ui.close(); - } - } - ui.separator(); - menu_nodes(app, ui, children, clicked); - }); + } } MenuNode::Submenu { label, children } => { - ui.menu_button((gutter(false), label.as_str()), |ui| { + let ws = app.ui.workspace.clone(); + let shown = effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws)); + ui.menu_button((gutter(false), shown.as_str()), |ui| { ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 }); // Long submenus (Blending Mode, effect categories) scroll instead of running // off the screen. @@ -1177,6 +1228,11 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c } } +/// Frontend state for dynamic menus (the current workspace). +pub(crate) fn dyn_ctx(workspace: &str) -> effectcraft_engine::menus::DynCtx<'_> { + effectcraft_engine::menus::DynCtx { workspace: Some(workspace) } +} + /// The check-mark column every menu row reserves (like macOS / After Effects menus). fn gutter(checked: bool) -> egui::Atom<'static> { use egui::AtomExt; diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index ef5070b3..42c3aacf 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -134,28 +134,41 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { let id = format!("{path}.{i}"); match n { MenuNode::Separator => out.push(NativeNode::Separator), + MenuNode::Dynamic { name } => { + // Recent projects / footage / presets, undo history, shortcut slots, viewers. + let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace)); + if entries.is_empty() + && let Some(e) = empty + { + out.push(NativeNode::Item(NativeItem { + id: format!("{id}.none"), + label: e.to_string(), + command: String::new(), + params: Value::Null, + shortcut: None, + accelerator: None, + enabled: false, + checked: None, + })); + } + for (k, e) in entries.iter().enumerate() { + out.push(NativeNode::Item(NativeItem { + id: format!("{id}.d{k}"), + label: e.label.clone(), + command: e.command.clone(), + params: e.params.clone(), + shortcut: None, + accelerator: None, + enabled: crate::menus::entry_enabled(app, e), + checked: None, + })); + } + } MenuNode::Submenu { label, children } => { let mut kids = vec![]; - if label == "Open Recent" { - for (k, p) in app.session.prefs.recent_projects.iter().enumerate() { - let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(p.clone()); - kids.push(NativeNode::Item(NativeItem { - id: format!("{id}.r{k}"), - label: name, - command: "file.openRecent".into(), - params: json!({"index": k}), - shortcut: None, - accelerator: None, - enabled: true, - checked: None, - })); - } - if !kids.is_empty() { - kids.push(NativeNode::Separator); - } - } rec(app, children, &id, &mut kids); - out.push(NativeNode::Submenu { label: label.clone(), children: kids }); + let shown = effectcraft_engine::menus::submenu_label(&app.session, label, &crate::menus::dyn_ctx(&app.ui.workspace)); + out.push(NativeNode::Submenu { label: shown, children: kids }); } MenuNode::Item(e) => { let role = match e.command.as_str() { @@ -272,6 +285,11 @@ pub fn state_key(app: &EffectcraftApp) -> u64 { s.journal.len().hash(&mut h); s.journal.last().map(|j| &j.0).hash(&mut h); s.prefs.recent_projects.hash(&mut h); + (&s.prefs.recent_footage, &s.prefs.recent_presets).hash(&mut h); + s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h)); + s.state.open_comps.hash(&mut h); + s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h); + s.shortcuts().keys.len().hash(&mut h); s.keymaps.active.hash(&mut h); let v = &app.ui.viewer; (v.rulers, v.guides, v.snap_guides, v.lock_guides, v.grid, v.snap_grid, v.show_layer_controls, v.pasteboard).hash(&mut h); @@ -340,7 +358,10 @@ mod tests { fn commands(n: &[NativeNode], out: &mut Vec<(String, String)>) { for x in n { match x { - NativeNode::Item(i) if i.command != "file.openRecent" => out.push((i.command.clone(), i.params.to_string())), + // Dynamic entries (recent files, History, viewers…) are not tree entries. + NativeNode::Item(i) if !i.id.rsplit('.').next().is_some_and(|l| l.starts_with('d') || l == "none") => { + out.push((i.command.clone(), i.params.to_string())) + } NativeNode::Predefined { command, .. } if !command.is_empty() => out.push((command.clone(), "null".into())), NativeNode::Submenu { children, .. } => commands(children, out), _ => {} @@ -431,7 +452,9 @@ mod tests { app.session.execute("layer.setSwitch", json!({"switch": "lock"})).unwrap(); assert_ne!(state_key(&app), k0); let spec1 = build(&app); - assert_eq!(spec0.structure(), spec1.structure(), "state changes don't rebuild the menu"); + // The new undo step joins Edit ▸ History (a dynamic submenu); nothing else is rebuilt. + let history = |s: &NativeMenu| s.items().into_iter().filter(|i| i.command == "edit.history").count(); + assert_eq!(history(&spec1), history(&spec0) + 1); let lock = spec1.items().into_iter().find(|i| i.command == "layer.setSwitch" && i.params["switch"] == "lock").unwrap().checked; assert_eq!(lock, Some(true)); assert_ne!(undo(&spec0), undo(&spec1)); diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs new file mode 100644 index 00000000..1efd6876 --- /dev/null +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -0,0 +1,394 @@ +//! The expression editor of the Timeline, following Settings ▸ Scripting & Expressions: +//! Font Size, Syntax Highlighting, Line Numbers, Auto-complete (Tab accepts), Bracket Matching +//! and Word Wrap; and the Composition panel's expression error banner (Show Expression Error +//! Banner). + +use effectcraft_engine::prefs::Scripting; +use egui::text::{LayoutJob, TextFormat}; +use egui::{Color32, FontId, Rect, Stroke, pos2, vec2}; + +use crate::theme::Tokens; + +/// Syntax colours. +const KEYWORD: Color32 = Color32::from_rgb(0xc6, 0x8a, 0xe6); +const NUMBER: Color32 = Color32::from_rgb(0xe0, 0xb4, 0x6c); +const STRING: Color32 = Color32::from_rgb(0x9c, 0xd0, 0x7a); +const COMMENT: Color32 = Color32::from_rgb(0x80, 0x88, 0x90); +const API: Color32 = Color32::from_rgb(0x6c, 0xb8, 0xf0); +const BRACKET_BG: Color32 = Color32::from_rgb(0x4a, 0x55, 0x6a); + +const KEYWORDS: &[&str] = &[ + "var", + "let", + "const", + "function", + "return", + "if", + "else", + "for", + "while", + "do", + "break", + "continue", + "new", + "true", + "false", + "null", + "undefined", + "this", + "typeof", + "in", + "of", + "switch", + "case", + "default", + "try", + "catch", + "throw", +]; + +/// Expression language names offered by auto-complete (public Expression Language Reference +/// vocabulary). +pub const COMPLETIONS: &[&str] = &[ + "thisComp", + "thisLayer", + "thisProperty", + "time", + "value", + "index", + "layer", + "effect", + "mask", + "content", + "transform", + "position", + "anchorPoint", + "scale", + "rotation", + "opacity", + "orientation", + "xRotation", + "yRotation", + "zRotation", + "wiggle", + "loopOut", + "loopIn", + "loopOutDuration", + "loopInDuration", + "valueAtTime", + "velocityAtTime", + "speedAtTime", + "linear", + "ease", + "easeIn", + "easeOut", + "clamp", + "random", + "gaussRandom", + "seedRandom", + "noise", + "degreesToRadians", + "radiansToDegrees", + "timeToFrames", + "framesToTime", + "timeToTimecode", + "length", + "lookAt", + "toComp", + "fromComp", + "toWorld", + "fromWorld", + "sourceRectAtTime", + "sourceText", + "numKeys", + "key", + "nearestKey", + "inPoint", + "outPoint", + "startTime", + "width", + "height", + "frameDuration", + "marker", + "Math", + "comp", + "footage", + "posterizeTime", + "add", + "sub", + "mul", + "div", + "normalize", + "cross", + "dot", + "hslToRgb", + "rgbToHsl", + "createPath", + "points", + "inTangents", + "outTangents", + "isClosed", +]; + +/// The bracket matching the one at or just before char index `cursor`: (open, close) char +/// indices. +pub fn matching_bracket(text: &str, cursor: usize) -> Option<(usize, usize)> { + let chars: Vec = text.chars().collect(); + let pairs = [('(', ')'), ('[', ']'), ('{', '}')]; + for at in [cursor, cursor.wrapping_sub(1)] { + let Some(&c) = chars.get(at) else { continue }; + if let Some(&(o, cl)) = pairs.iter().find(|(o, _)| *o == c) { + let mut depth = 0; + for (i, &x) in chars.iter().enumerate().skip(at) { + if x == o { + depth += 1; + } else if x == cl { + depth -= 1; + if depth == 0 { + return Some((at, i)); + } + } + } + } + if let Some(&(o, cl)) = pairs.iter().find(|(_, cl)| *cl == c) { + let mut depth = 0; + for i in (0..=at).rev() { + if chars[i] == cl { + depth += 1; + } else if chars[i] == o { + depth -= 1; + if depth == 0 { + return Some((i, at)); + } + } + } + } + } + None +} + +/// The identifier being typed before char index `cursor`, and auto-complete candidates for it. +pub fn completions(text: &str, cursor: usize) -> (String, Vec<&'static str>) { + let chars: Vec = text.chars().collect(); + let end = cursor.min(chars.len()); + let mut start = end; + while start > 0 && (chars[start - 1].is_alphanumeric() || chars[start - 1] == '_') { + start -= 1; + } + let word: String = chars[start..end].iter().collect(); + if word.chars().count() < 2 || word.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return (word, vec![]); + } + let c: Vec<&str> = COMPLETIONS.iter().copied().filter(|w| w.starts_with(word.as_str()) && *w != word).take(6).collect(); + (word, c) +} + +/// Colour runs for JavaScript: (byte start, byte end, colour). +pub fn highlight(text: &str, base: Color32) -> Vec<(usize, usize, Color32)> { + let b = text.as_bytes(); + let mut out = vec![]; + let mut i = 0; + while i < b.len() { + let c = b[i]; + let start = i; + let col = if c == b'/' && b.get(i + 1) == Some(&b'/') { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + COMMENT + } else if c == b'/' && b.get(i + 1) == Some(&b'*') { + i += 2; + while i < b.len() && !(b[i] == b'*' && b.get(i + 1) == Some(&b'/')) { + i += 1; + } + i = (i + 2).min(b.len()); + COMMENT + } else if c == b'"' || c == b'\'' || c == b'`' { + i += 1; + while i < b.len() && b[i] != c { + i += if b[i] == b'\\' { 2 } else { 1 }; + } + i = (i + 1).min(b.len()); + STRING + } else if c.is_ascii_digit() { + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'.') { + i += 1; + } + NUMBER + } else if c.is_ascii_alphabetic() || c == b'_' || c == b'$' { + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'$') { + i += 1; + } + let w = &text[start..i]; + if KEYWORDS.contains(&w) { + KEYWORD + } else if COMPLETIONS.contains(&w) { + API + } else { + base + } + } else { + // Advance one whole character. + i += text[i..].chars().next().map_or(1, char::len_utf8); + base + }; + match out.last_mut() { + Some((_, e, c)) if *c == col && *e == start => *e = i, + _ => out.push((start, i, col)), + } + } + out +} + +/// Byte index of char index `ci`. +fn byte_of(text: &str, ci: usize) -> usize { + text.char_indices().nth(ci).map(|(b, _)| b).unwrap_or(text.len()) +} + +/// The layout job of the editor text: syntax colours, the matched brackets' background, wrap. +fn layout(text: &str, font: FontId, base: Color32, p: &Scripting, brackets: Option<(usize, usize)>, wrap: f32) -> LayoutJob { + let mut job = LayoutJob::default(); + let runs = if p.syntax_highlighting { highlight(text, base) } else { vec![(0, text.len(), base)] }; + let marks: Vec = brackets.filter(|_| p.bracket_matching).map(|(a, b)| vec![byte_of(text, a), byte_of(text, b)]).unwrap_or_default(); + for (s, e, col) in runs { + // Split runs at the marked brackets. + let mut cuts = vec![s]; + for m in &marks { + if *m >= s && *m < e { + cuts.push(*m); + cuts.push((*m + 1).min(e)); + } + } + cuts.push(e); + cuts.sort_unstable(); + cuts.dedup(); + for w in cuts.windows(2) { + let bg = if marks.contains(&w[0]) { BRACKET_BG } else { Color32::TRANSPARENT }; + job.append(&text[w[0]..w[1]], 0.0, TextFormat { font_id: font.clone(), color: col, background: bg, ..Default::default() }); + } + } + job.wrap.max_width = if p.word_wrap { wrap } else { f32::INFINITY }; + job +} + +/// The editor: returns the response; `buf` is edited in place. +pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: &Scripting, color: Color32, rows: usize, t: &Tokens) -> egui::Response { + let size = (p.editor_font_size.clamp(8, 40) as f32) * 0.9; + let font = Tokens::mono(size); + // Line numbers in a gutter on the left. + let gutter = if p.line_numbers { (size * 0.62) * (buf.lines().count().max(1).to_string().len() as f32 + 1.0) + 6.0 } else { 0.0 }; + let er = Rect::from_min_max(pos2(rect.min.x + gutter, rect.min.y), rect.max); + let cursor = egui::TextEdit::load_state(ui.ctx(), id).and_then(|s| s.cursor.char_range()).map(|r| r.primary.index.0); + let brackets = cursor.and_then(|c| matching_bracket(buf, c)); + let pc = p.clone(); + let mut layouter = move |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap: f32| { + let job = layout(text.as_str(), font.clone(), color, &pc, brackets, wrap); + ui.fonts_mut(|f| f.layout_job(job)) + }; + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er)); + // Auto-complete: Tab accepts the first candidate (consumed before the editor sees it). + let mut accepted = None; + if p.auto_complete + && child.memory(|m| m.has_focus(id)) + && let Some(c) = cursor + { + let (word, cands) = completions(buf, c); + if let Some(first) = cands.first() + && child.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)) + { + let at = byte_of(buf, c); + buf.insert_str(at, &first[word.len()..]); + accepted = Some(c + first.chars().count() - word.chars().count()); + } + } + let out = egui::TextEdit::multiline(buf) + .id(id) + .font(Tokens::mono(size)) + .desired_width(er.width()) + .desired_rows(rows) + .frame(egui::Frame::NONE) + .lock_focus(true) + .layouter(&mut layouter) + .show(&mut child); + if let Some(c) = accepted { + let mut st = out.state.clone(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(c)))); + st.store(ui.ctx(), id); + } + let painter = ui.painter(); + if p.line_numbers { + let g = out.galley.clone(); + let mut line = 1; + let mut new_line = true; + for row in &g.rows { + if new_line { + let y = out.galley_pos.y + row.rect().center().y; + painter.text(pos2(rect.min.x + gutter - 6.0, y), egui::Align2::RIGHT_CENTER, line.to_string(), Tokens::mono(size * 0.85), t.text_faint); + line += 1; + } + new_line = row.ends_with_newline; + } + painter.line_segment([pos2(rect.min.x + gutter - 3.0, rect.min.y), pos2(rect.min.x + gutter - 3.0, rect.max.y)], Stroke::new(1.0, t.separator)); + } + // Candidate list under the editor. + if p.auto_complete + && out.response.has_focus() + && let Some(c) = out.cursor_range.map(|r| r.primary.index.0) + { + let (_, cands) = completions(buf, c); + if !cands.is_empty() { + let pos = out.galley_pos + out.galley.pos_from_cursor(egui::text::CCursor::new(c)).left_bottom().to_vec2() + vec2(0.0, 2.0); + egui::Area::new(id.with("complete")).order(egui::Order::Tooltip).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + for (i, w) in cands.iter().enumerate() { + let txt = if i == 0 { format!("{w} Tab") } else { w.to_string() }; + ui.label(egui::RichText::new(txt).font(Tokens::mono(size * 0.9)).color(if i == 0 { t.text } else { t.text_dim })); + } + }); + }); + } + } + out.response.response +} + +/// Settings ▸ Scripting & Expressions ▸ Show Expression Error Banner: the first failing +/// expression of the comp at the current time, in a banner along the bottom of the viewer +/// (click it to reveal the property). +pub fn error_banner(app: &mut crate::EffectcraftApp, ui: &egui::Ui, area: Rect) { + if !app.session.prefs.scripting.error_banner { + return; + } + let Some(cid) = app.session.active_comp_id() else { return }; + let key = (app.session.revision, cid.0, app.session.time().0); + let id = egui::Id::new("expr-error-banner"); + let cached: Option<((u64, u64, i64), Vec)> = ui.ctx().data(|d| d.get_temp(id)); + let errors = match cached { + Some((k, e)) if k == key => e, + _ => { + let e = app.session.expression_errors(cid); + ui.ctx().data_mut(|d| d.insert_temp(id, (key, e.clone()))); + e + } + }; + let Some(first) = errors.first() else { return }; + let layer = first["layer"].as_u64().unwrap_or(0); + let lname = app.session.active_comp().and_then(|c| c.layer(effectcraft_engine::project::LayerId(layer))).map(|l| l.name.clone()).unwrap_or_default(); + let msg = format!( + "⚠ Expression error{}: {} ▸ {}: {}", + if errors.len() > 1 { format!(" (1 of {})", errors.len()) } else { String::new() }, + lname, + first["name"].as_str().unwrap_or(""), + first["error"].as_str().unwrap_or("").lines().next().unwrap_or("") + ); + let r = Rect::from_min_max(pos2(area.min.x, area.max.y - 22.0), area.max); + let p = ui.painter(); + p.rect_filled(r, 0.0, Color32::from_rgb(0xf0, 0xc0, 0x30)); + p.text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, msg, Tokens::ui(11.5), Color32::BLACK); + app.auto.add("viewer.expressionErrorBanner", r, "Expression error banner"); + if ui.interact(r, id.with("click"), egui::Sense::click()).clicked() { + let p = serde_json::json!({"props": [{"layer": layer, "prop": first["prop"]}]}); + if let Err(e) = crate::menus::invoke(app, ui.ctx(), "timeline.revealProps", p) { + app.ui.status = e; + } + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d0fb3a3e..96a0d8d5 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -9,6 +9,7 @@ pub mod effect_controls; pub mod effects_presets; pub mod essential; pub mod expr_bar; +pub mod expr_editor; pub mod flowchart; pub mod forms; pub mod fx_widgets; diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index fef958a7..6f72af12 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -98,9 +98,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { widgets::open_popup(ui, pop); } app.auto.add("character.font", fr, "Font family"); - let fams: Vec = effectcraft_engine::text_families(); - if let Some(i) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)) { - actions.push(json!({"font": fams[i]})); + if let Some(f) = font_popup(app, ui, pop, fr.left_bottom(), &doc.font) { + actions.push(json!({"font": f})); } y += 28.0; let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); @@ -365,20 +364,29 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } y += 3.0 * 28.0 + 6.0; - // Direction and composer popups. - let dr = Rect::from_min_size(pos2(x0, y), vec2(w / 2.0 - 4.0, 22.0)); - let dirs = vec!["Left-to-Right Text".to_string(), "Right-to-Left Text".to_string()]; - let dpop = egui::Id::new("para-direction-pop"); + // Direction and composer popups. Settings ▸ Type ▸ Text Engine: the South Asian and Middle + // Eastern engine offers the paragraph direction and the World-Ready composers (the Latin + // engine shows the direction only for right-to-left text already set). + let world_ready = app.session.prefs.type_.text_engine == "southAsian"; let rtl = doc.direction == Direction::Rtl; - if widgets::dropdown(ui, dr, &dirs[rtl as usize], &t, egui::Id::new("para-direction")).clicked() && enabled { - widgets::open_popup(ui, dpop); - } - app.auto.add("paragraph.direction", dr, "Text direction"); - if let Some(i) = widgets::popup_menu(ui, dpop, dr.left_bottom(), &dirs, Some(rtl as usize)) { - actions.push(json!({"direction": if i == 1 { "rtl" } else { "ltr" }})); + if world_ready || rtl { + let dr = Rect::from_min_size(pos2(x0, y), vec2(w / 2.0 - 4.0, 22.0)); + let dirs = vec!["Left-to-Right Text".to_string(), "Right-to-Left Text".to_string()]; + let dpop = egui::Id::new("para-direction-pop"); + if widgets::dropdown(ui, dr, &dirs[rtl as usize], &t, egui::Id::new("para-direction")).clicked() && enabled { + widgets::open_popup(ui, dpop); + } + app.auto.add("paragraph.direction", dr, "Text direction"); + if let Some(i) = widgets::popup_menu(ui, dpop, dr.left_bottom(), &dirs, Some(rtl as usize)) { + actions.push(json!({"direction": if i == 1 { "rtl" } else { "ltr" }})); + } } let cr = Rect::from_min_size(pos2(x0 + w / 2.0, y), vec2(w / 2.0, 22.0)); - let comps = vec!["Every-line Composer".to_string(), "Single-line Composer".to_string()]; + let comps = if world_ready { + vec!["World-Ready Every-line Composer".to_string(), "World-Ready Single-line Composer".to_string()] + } else { + vec!["Every-line Composer".to_string(), "Single-line Composer".to_string()] + }; let cpop = egui::Id::new("para-composer-pop"); let single = doc.composer == Composer::SingleLine; if widgets::dropdown(ui, cr, &comps[single as usize], &t, egui::Id::new("para-composer")).clicked() && enabled { @@ -406,6 +414,56 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } +/// The font menu: recent fonts first, names in English or the fonts' own language, and a +/// "Sample" preview in each font (Settings ▸ Type). +fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, current: &str) -> Option { + if !ui.data(|d| d.get_temp::(id.with("open")).unwrap_or(false)) { + return None; + } + let rows = effectcraft_engine::font_menu(&app.session.prefs); + let preview = app.session.prefs.type_.font_preview; + let t = app.tokens; + let mut chosen = None; + let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(if preview { 300.0 } else { 180.0 }); + egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + for r in &rows { + if r.family.is_empty() { + ui.separator(); + continue; + } + let resp = ui.selectable_label(r.family == current, &r.display); + if preview && ui.is_rect_visible(resp.rect) { + let key = egui::Id::new(("font-preview", &r.family)); + let lines: std::sync::Arc>> = match ui.data(|d| d.get_temp(key)) { + Some(l) => l, + None => { + let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0)); + ui.data_mut(|d| d.insert_temp(key, l.clone())); + l + } + }; + let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0); + for poly in lines.iter() { + let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); + ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + } + } + if resp.clicked() { + chosen = Some(r.family.clone()); + } + } + }); + }); + }); + let outside = ui.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer() && !area.response.hovered(); + if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(id.with("open"), false)); + } + chosen +} + /// Align panel: align selected layers to the composition (or to the selection). pub fn align(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 10ad62e6..2cf9a0cb 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1573,15 +1573,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0)); let buf_id = egui::Id::new(("expr-buf", uid)); let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er)); - let resp = child.add( - egui::TextEdit::multiline(&mut buf) - .id(egui::Id::new(("expr-edit", uid))) - .font(Tokens::mono(11.5)) - .text_color(if ex.enabled { expr_col } else { t.text_dim }) - .desired_width(er.width()) - .desired_rows((*lines).clamp(1, 8)) - .frame(egui::Frame::NONE), + // Settings ▸ Scripting & Expressions ▸ Expressions Editor. + let sp = app.session.prefs.scripting.clone(); + let resp = super::expr_editor::editor( + ui, + egui::Id::new(("expr-edit", uid)), + &mut buf, + er, + &sp, + if ex.enabled { expr_col } else { t.text_dim }, + (*lines).clamp(1, 8), + &t, ); app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name); let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl)); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 04999a90..ccb963cf 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -517,12 +517,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let sub = g.grid_subdivisions.max(1) as f32; let sstep = step / sub; let draw_minor = sstep >= 4.0; + // Settings ▸ Grids & Guides ▸ Grid Style (lines, dashed lines or dots). + let style = g.grid_style.clone(); let mut i = 0u32; let mut gx = comp_rect.min.x; while gx <= comp_rect.max.x { let is_major = i.is_multiple_of(g.grid_subdivisions.max(1)); if is_major || draw_minor { - painter.line_segment([pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], if is_major { major } else { minor }); + styled_line(&painter, [pos2(gx, comp_rect.min.y), pos2(gx, comp_rect.max.y)], if is_major { major } else { minor }, &style); } gx += sstep; i += 1; @@ -532,7 +534,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { while gy <= comp_rect.max.y { let is_major = i.is_multiple_of(g.grid_subdivisions.max(1)); if is_major || draw_minor { - painter.line_segment([pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], if is_major { major } else { minor }); + styled_line(&painter, [pos2(comp_rect.min.x, gy), pos2(comp_rect.max.x, gy)], if is_major { major } else { minor }, &style); } gy += sstep; i += 1; @@ -545,13 +547,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if app.ui.viewer.guides { let [r, gg, b] = hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]); let stroke = Stroke::new(1.0, Color32::from_rgb(r, gg, b)); + // Settings ▸ Grids & Guides ▸ Guide Style. + let style = app.session.prefs.grids.guide_style.clone(); for g in &comp.guides { if g.vertical { let x = comp_rect.min.x + g.position as f32 * zoom; - painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], stroke); + styled_line(&painter, [pos2(x, area.min.y), pos2(x, area.max.y)], stroke, &style); } else { let y = comp_rect.min.y + g.position as f32 * zoom; - painter.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], stroke); + styled_line(&painter, [pos2(area.min.x, y), pos2(area.max.x, y)], stroke, &style); } } } @@ -613,6 +617,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let hover = ui.input(|i| i.pointer.hover_pos()); ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut paths); } + // Settings ▸ Previews ▸ Show Internal Wireframes: outlines of the layers inside a + // selected collapsed precomp. + if app.session.prefs.previews.show_internal_wireframes + && l.switches.collapse + && let effectcraft_engine::project::LayerSource::Comp { item } = &l.source + && let Some(nc) = app.session.project.comp(*item) + { + let (outer, _) = l2c(&ectx, l); + let nctx = EvalCtx::new(&app.session.project, *item, nc, ectx.source_time(l)); + for nl in nc.layers.iter().filter(|nl| nl.is_active_at(nctx.time) && !nl.is_3d()) { + if let Some((_, q, _)) = layer_quad(&nctx, nl) { + let sq: Vec = q + .iter() + .map(|p| { + let c = outer.apply(gv2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }) + .collect(); + painter.add(egui::Shape::closed_line(sq, Stroke::new(1.0, col.gamma_multiply(0.6)))); + } + } + } let Some((m, q, _)) = layer_quad(&ectx, l) else { // Cameras, lights, empty layers: just the anchor. if let Some(tr) = l.transform() { @@ -661,6 +687,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } + // Settings ▸ 3D: Extended Viewer (3D layers outlined beyond the comp frame) and Show 3D + // Reference Axes (world X/Y/Z in the corner, turning with the view). + let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time)); + if has_3d && app.session.prefs.three_d.extended_viewer { + for l in comp.layers.iter().filter(|l| l.is_3d() && l.is_active_at(time) && l.has_video()) { + if let Some((_, q, _)) = layer_quad(&ectx, l) { + let sq: Vec = q.iter().map(|p| map.to_screen(*p)).collect(); + let outside = sq.iter().any(|p| !comp_rect.contains(*p)); + if outside { + painter.add(egui::Shape::closed_line(sq, Stroke::new(1.0, t.label(l.label).gamma_multiply(0.55)))); + } + } + } + } + if has_3d && app.session.prefs.three_d.show_reference_axes { + let pc = cam_state(&ectx).projection(cw as f64, ch as f64); + let o3 = effectcraft_engine::geom::vec3(cw as f64 / 2.0, ch as f64 / 2.0, 0.0); + let o = pc.apply(o3); + let corner = pos2(area.min.x + 34.0, area.max.y - 34.0); + for (axis, col, name) in [ + (effectcraft_engine::geom::vec3(1.0, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50), "X"), + (effectcraft_engine::geom::vec3(0.0, 1.0, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60), "Y"), + (effectcraft_engine::geom::vec3(0.0, 0.0, 1.0), Color32::from_rgb(0x50, 0x8c, 0xf0), "Z"), + ] { + let e = pc.apply(o3 + axis * 10.0); + let d = vec2((e.x - o.x) as f32, (e.y - o.y) as f32); + let d = if d.length() > 1e-4 { d.normalized() * 22.0 } else { vec2(0.0, 0.0) }; + painter.arrow(corner, d, Stroke::new(1.5, col)); + painter.text(corner + d * 1.25, Align2::CENTER_CENTER, name, Tokens::ui(10.0), col); + } + app.auto.add("viewer.referenceAxes", Rect::from_center_size(corner, vec2(56.0, 56.0)), "3D reference axes"); + } + + super::expr_editor::error_banner(app, ui, area); + // Track points of the current track (Tracker panel) on its layer, while that layer is selected. let mut track_hits = vec![]; let track_cur = super::tracker::viewer_track(app); @@ -1564,6 +1625,26 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } /// `#rrggbb` → sRGB bytes. +/// A grid or guide line in the Grids & Guides style: `lines`, `dashed` or `dots`. +pub(crate) fn styled_line(painter: &egui::Painter, pts: [Pos2; 2], stroke: Stroke, style: &str) { + match style { + "dashed" => { + painter.add(egui::Shape::dashed_line(&pts, stroke, 6.0, 4.0)); + } + "dots" => { + painter.add(egui::Shape::dotted_line(&pts, stroke.color, 4.0, stroke.width.max(1.0) * 0.75)); + } + _ => { + painter.line_segment(pts, stroke); + } + } +} + +/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest. +pub(crate) fn zoom_texture_options(smooth: bool) -> egui::TextureOptions { + if smooth { egui::TextureOptions::LINEAR } else { egui::TextureOptions::NEAREST } +} + pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> { let h = s.trim().trim_start_matches('#'); if h.len() != 6 || !h.is_ascii() { @@ -1577,14 +1658,17 @@ pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> { /// straight from their wgpu texture (registered with egui-wgpu, no readback). fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames::FrameKey, img: crate::frames::FrameImage) { use crate::frames::FrameImage; + // Settings ▸ Previews ▸ Viewer Zoom Quality. + let smooth = app.session.prefs.viewer_zoom_smooth(); + let opts = zoom_texture_options(smooth); match img { FrameImage::Cpu(img) => { match &mut app.viewer_tex { Some((tex, k)) if tex.size() == img.size => { - tex.set((*img).clone(), egui::TextureOptions::LINEAR); + tex.set((*img).clone(), opts); *k = key; } - _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), egui::TextureOptions::LINEAR), key)), + _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), opts), key)), } app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k)| (t.id(), *k)); app.viewer_image = Some(img); @@ -1594,12 +1678,13 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: let Some(rs) = &app.wgpu else { return }; let view = f.texture.create_view(&Default::default()); let mut ren = rs.renderer.write(); + let filter = if smooth { eframe::wgpu::FilterMode::Linear } else { eframe::wgpu::FilterMode::Nearest }; let id = match &app.viewer_native { Some((id, _, _)) => { - ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, eframe::wgpu::FilterMode::Linear, *id); + ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, filter, *id); *id } - None => ren.register_native_texture(&rs.device, &view, eframe::wgpu::FilterMode::Linear), + None => ren.register_native_texture(&rs.device, &view, filter), }; drop(ren); app.viewer_native = Some((id, key, f)); diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 5424ed83..4d3bd312 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -269,7 +269,9 @@ pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map let c = p2c.apply(gv2(v[0], v[1])); map.to_screen([c.x, c.y]) }; - let (lt0, lt1) = (pos.keys[0].time, pos.keys[pos.keys.len() - 1].time); + // Settings ▸ Composition ▸ Motion Path: all keyframes, none, or a window around the CTI. + let times: Vec = pos.keys.iter().map(|k| k.time).collect(); + let Some((lt0, lt1)) = app.session.prefs.motion_path_span(×, l.layer_time(ectx.time)) else { return }; let fd = ectx.comp.frame_duration(); let mut pts = vec![]; let mut lt = lt0; @@ -285,6 +287,9 @@ pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map } let sel = &app.session.state.selected_keys; for (i, k) in pos.keys.iter().enumerate() { + if k.time < lt0 || k.time > lt1 { + continue; + } let v = k.value.as_vec3(); let s = to_scr(v); let (tin, tout) = effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index ff98f778..0ce1b29c 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -282,11 +282,11 @@ pub(crate) fn proportional_grid(app: &EffectcraftApp, painter: &egui::Painter, c let (nx, ny) = (g.proportional_horizontal.max(1), g.proportional_vertical.max(1)); for i in 1..nx { let x = comp_rect.min.x + comp_rect.width() * i as f32 / nx as f32; - painter.line_segment([pos2(x, comp_rect.min.y), pos2(x, comp_rect.max.y)], s); + super::viewer::styled_line(painter, [pos2(x, comp_rect.min.y), pos2(x, comp_rect.max.y)], s, &g.grid_style); } for i in 1..ny { let y = comp_rect.min.y + comp_rect.height() * i as f32 / ny as f32; - painter.line_segment([pos2(comp_rect.min.x, y), pos2(comp_rect.max.x, y)], s); + super::viewer::styled_line(painter, [pos2(comp_rect.min.x, y), pos2(comp_rect.max.x, y)], s, &g.grid_style); } } @@ -324,6 +324,11 @@ fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32, pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) { let opts = app.session.state.viewer.clone(); let snap = showing_snapshot(app, ctx); + // Settings ▸ Video ▸ Mirror on Computer Monitor off: playback goes to Video Preview only. + if crate::prefs_live::main_viewer_hidden(app) && !snap { + painter.text(comp_rect.center(), Align2::CENTER_CENTER, "Playing on Video Preview", Tokens::ui(13.0), Color32::from_gray(150)); + return; + } if opts.fast_previews == FastPreviews::Wireframe && !snap { let zoom = comp_rect.width() / ectx.comp.width.max(1) as f32; let map = ViewerMap { origin: comp_rect.min, zoom, comp: [ectx.comp.width as f32, ectx.comp.height as f32], area: comp_rect }; @@ -400,6 +405,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: rect }; let Some(src) = app.viewer_image.clone() else { return }; + let zoom_opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -410,7 +416,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) { Some((k, tex)) if k == key => tex, _ => { - let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), egui::TextureOptions::LINEAR); + let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), zoom_opts); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex } diff --git a/crates/ui-egui/src/panels/waveform.rs b/crates/ui-egui/src/panels/waveform.rs index f9bf00db..ab3fbd79 100644 --- a/crates/ui-egui/src/panels/waveform.rs +++ b/crates/ui-egui/src/panels/waveform.rs @@ -47,10 +47,25 @@ pub fn summary(app: &EffectcraftApp, ctx: &egui::Context, item: ItemId) -> Optio let ItemKind::Footage(f) = &app.session.project.item(item)?.kind else { return None }; m.insert(item.0, None); let (f, footage, map, ctx) = (f.clone(), app.session.footage.clone(), app.waveforms.map.clone(), ctx.clone()); + // Settings ▸ Disk ▸ Database and Cache Folder: summaries are kept on disk between sessions. + let cache = app.session.media_cache_folder(); std::thread::Builder::new() .name("ec-waveform".into()) .spawn(move || { - let peaks = effectcraft_engine::render::audio::footage_peaks(footage.as_ref(), item, &f, f.duration, BINS_PER_SEC); + use effectcraft_engine::media_cache; + let file = cache.and_then(|d| media_cache::peaks_path(&d, &f.path, BINS_PER_SEC)); + let peaks = match file.as_deref().and_then(media_cache::load_peaks) { + Some(p) => p, + None => { + let p = effectcraft_engine::render::audio::footage_peaks(footage.as_ref(), item, &f, f.duration, BINS_PER_SEC); + if let Some(file) = &file + && let Err(e) = media_cache::store_peaks(file, &p) + { + log::warn!("media cache: {}: {e}", file.display()); + } + p + } + }; if let Ok(mut m) = map.lock() { m.insert(item.0, Some(Arc::new(Summary { bins_per_sec: BINS_PER_SEC, peaks }))); } diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs new file mode 100644 index 00000000..9e730196 --- /dev/null +++ b/crates/ui-egui/src/prefs_live.rs @@ -0,0 +1,141 @@ +//! Settings the desktop frontend applies every frame: dropped files (Import ▸ Default Drag +//! Import As), the memory watch (Memory & CPU ▸ RAM Reserved / Reduce Cache Size When System Is +//! Low on Memory), Startup ▸ Show Home Screen When Opening a Project, and Video ▸ Video Preview +//! output (a second window showing the composition frame, Mercury Transmit-style). + +use egui::{Color32, Rect}; +use serde_json::{Value, json}; + +use crate::EffectcraftApp; +use crate::frames::FrameKey; + +/// Run once per frame, before the panels draw. +pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) { + dropped_files(app, ctx); + memory_watch(app, ctx); + home_on_open(app, ctx); + video_preview(app, ctx); +} + +/// Files dropped on the window are imported (layered files as Default Drag Import As says). +fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) { + let paths: Vec = + ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| std::path::Path::new(p).is_absolute()).collect()); + if paths.is_empty() { + return; + } + let (proj, rest): (Vec, Vec) = paths.into_iter().partition(|p| { + let l = p.to_ascii_lowercase(); + l.ends_with(".ecproj") || l.ends_with(".ecprojx") + }); + if let Some(p) = proj.first() + && let Err(e) = crate::menus::invoke(app, ctx, "file.open", json!({"path": p})) + { + app.ui.status = e; + } + if !rest.is_empty() { + let params = json!({"paths": rest, "drag": true, "importAs": app.session.prefs.drag_import_as()}); + if let Err(e) = crate::menus::invoke(app, ctx, "file.import", params) { + app.ui.status = e; + } + } +} + +/// Re-read the system's free memory every few seconds (cache budgets follow it). +fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + let id = egui::Id::new("prefs-memory-watch"); + let last = ctx.data(|d| d.get_temp::(id)); + if last.is_some_and(|t| now - t < 10.0) { + return; + } + ctx.data_mut(|d| d.insert_temp(id, now)); + // The first frame only records the time: tests and short runs don't query the system. + if last.is_some() { + app.session.memory_tick(); + } +} + +/// Startup ▸ Show Home Screen When Opening a Project. +fn home_on_open(app: &mut EffectcraftApp, ctx: &egui::Context) { + let id = egui::Id::new("prefs-last-project"); + let path = app.session.path.clone(); + let last = ctx.data(|d| d.get_temp::>(id)); + ctx.data_mut(|d| d.insert_temp(id, path.clone())); + let Some(last) = last else { return }; + let opened = app.session.journal.last().is_some_and(|(c, _)| matches!(c.as_str(), "file.open" | "file.openRecent" | "file.revert")); + if path.is_some() && path != last && opened && app.session.prefs.startup.show_home_on_open_project { + app.ui.start_screen = true; + } +} + +/// Video ▸ Mirror on Computer Monitor off: during playback the Composition panel leaves the +/// frame to the Video Preview window. +pub fn main_viewer_hidden(app: &EffectcraftApp) -> bool { + let v = &app.session.prefs.video; + v.enable_output && !v.mirror_on_monitor && app.playback.playing +} + +/// Whether the Video Preview window shows now (Disable Video Output When in Background). +pub fn video_preview_active(app: &EffectcraftApp, focused: bool) -> bool { + let v = &app.session.prefs.video; + v.enable_output && (focused || !v.disable_when_background) +} + +/// Video ▸ Enable Video Preview Output: a second native window with the current composition +/// frame, letterboxed on black. Video Device "Full Screen" opens it full screen (move it to the +/// external display first); Video Output During Playback off holds the last frame while playing. +fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { + let focused = ctx.input(|i| i.focused); + if !video_preview_active(app, focused) { + return; + } + let v = app.session.prefs.video.clone(); + let tex_id = egui::Id::new("video-preview-tex"); + let update = !app.playback.playing || v.output_during_playback; + let key = app.viewer_shown.as_ref().map(|(_, k)| *k); + let cur: Option<(FrameKey, egui::TextureHandle)> = ctx.data(|d| d.get_temp(tex_id)); + let tex = match (update, key, cur) { + (true, Some(k), Some((ck, t))) if ck == k => Some(t), + (true, Some(k), prev) => match app.viewer_pixels() { + Some(img) => { + let opts = crate::panels::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); + let t = match prev { + Some((_, mut t)) => { + t.set((*img).clone(), opts); + t + } + None => ctx.load_texture("video-preview", (*img).clone(), opts), + }; + ctx.data_mut(|d| d.insert_temp(tex_id, (k, t.clone()))); + Some(t) + } + None => prev.map(|p| p.1), + }, + (_, _, prev) => prev.map(|p| p.1), + }; + let title = + format!("Video Preview — {}", app.session.active_comp_id().and_then(|c| app.session.project.item(c)).map(|i| i.name.clone()).unwrap_or_default()); + let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([960.0, 540.0]); + if v.device == "fullscreen" { + builder = builder.with_fullscreen(true); + } + let mut closed = false; + ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("effectcraft-video-preview"), builder, |vctx, _| { + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::BLACK)).show(vctx, |ui| { + let area = ui.max_rect(); + if let Some(t) = &tex { + let [w, h] = t.size().map(|x| x.max(1) as f32); + let s = (area.width() / w).min(area.height() / h); + let r = Rect::from_center_size(area.center(), egui::vec2(w * s, h * s)); + ui.painter().image(t.id(), r, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + } + }); + if vctx.input(|i| i.viewport().close_requested()) { + closed = true; + } + }); + if closed && let Err(e) = app.set_pref("video.enableOutput", Value::Bool(false)) { + app.ui.status = e; + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c32ecb24..eb02bf48 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -75,6 +75,29 @@ pub struct Tokens { pub gap: f32, pub tab_h: f32, pub row_h: f32, + /// Settings ▸ Appearance ▸ Use Gradients: panel tab strips and buttons get a soft vertical + /// gradient (off: flat fills). + pub gradients: bool, +} + +/// A vertical gradient fill (`top` → `bottom`) over `rect`. +pub fn gradient_rect(painter: &egui::Painter, rect: egui::Rect, top: Color32, bottom: Color32) { + let mut m = egui::Mesh::default(); + m.colored_vertex(rect.left_top(), top); + m.colored_vertex(rect.right_top(), top); + m.colored_vertex(rect.right_bottom(), bottom); + m.colored_vertex(rect.left_bottom(), bottom); + m.add_triangle(0, 1, 2); + m.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(m)); +} + +impl Tokens { + /// The gradient top colour for a fill (a little lighter), or the fill itself when gradients + /// are off. + pub fn grad_top(&self, c: Color32) -> Color32 { + if self.gradients { c.lerp_to_gamma(Color32::WHITE, 0.06) } else { c } + } } impl Tokens { @@ -123,6 +146,7 @@ impl Tokens { gap: 4.0, tab_h: 30.0, row_h: 22.0, + gradients: true, }; match kind { ThemeKind::Dark => dark, @@ -192,6 +216,7 @@ impl Tokens { let [r, g, b] = p.label_rgb(*l); t.labels[i] = Color32::from_rgb(r, g, b); } + t.gradients = p.appearance.use_gradients; t } diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index 2c943740..2c9af608 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -134,7 +134,9 @@ fn double_click_enters_editing_and_selects_words() { assert_eq!(edited(&h).1, "alpha gamma"); key(&mut h, Key::Z, Modifiers::COMMAND); assert_eq!(edited(&h).1, "alpha beta"); - // Paragraph panel: alignment applies to the edited paragraph. + // Paragraph panel: alignment applies to the edited paragraph (the South Asian and Middle + // Eastern text engine shows the direction popup too). + h.state_mut().session.prefs.type_.text_engine = "southAsian".into(); h.state_mut().show_panel(PanelKind::Paragraph); h.run_steps(3); let r = rect(&h, "paragraph.right"); diff --git a/docs/preferences.md b/docs/preferences.md index 4e37704e..a1123e37 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -70,72 +70,80 @@ Commands (CLI, MCP, control channel): - `threeD.defaultRenderer`: Default 3D Renderer - `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu, the timeline, project panel, render queue and viewer handles +- `general.switchesAffectNestedComps`: a precomp layer's Quality (Draft / Wireframe) and Motion + Blur switches limit the layers of the nested comp (viewer, Render Queue, `render`) +- `general.preserveConstantVertexCount`: adding or deleting mask / shape path vertices at one + keyframe does the same on every other keyframe (added vertices split the matching segment) +- `general.syncTimeRelatedItems`: moving the current time moves it in the comps nested in this + one and in the comps that nest it (through the precomp layers' timing) +- `general.expressionPickWhipCompact`: off, the pick whip writes match names + (`thisComp.layer("A")("transform")("opacity")`) +- `general.createSplitLayersAbove`: where Edit ▸ Split Layer puts the new layer +- `startup.showHomeOnOpenProject`: the Home screen comes up after File ▸ Open / Open Recent +- `composition.motionPath`, `composition.motionPathSeconds`, `composition.motionPathKeyframes`: + how much of a position motion path the viewer draws (all, none, N seconds or N keyframes + around the current time) +- `previews.showInternalWireframes`: outlines of the layers inside a selected collapsed precomp +- `previews.zoomQuality`: Faster = nearest-neighbour viewer scaling, More Accurate = bilinear +- `previews.displayProfile`: the monitor's colour space for View ▸ Use Display Color Management +- `appearance.useLabelColorForTabs`: Composition / Timeline tabs show the comp's label colour, + Effect Controls / Properties the layer's +- `appearance.cycleMaskColors`: new masks cycle through the mask colours (off: all the first) +- `appearance.useGradients`: soft gradient on panel tab strips +- `grids.gridStyle`, `grids.guideStyle`: lines, dashed lines or dots (grid, proportional grid, + guides); `grids.proportionalHorizontal` / `grids.proportionalVertical`: View ▸ Show + Proportional Grid divisions +- `type.textEngine`: South Asian and Middle Eastern shows the paragraph direction and the + World-Ready composers in the Paragraph panel +- `type.fontPreview`: a "Sample" preview in each font of the Character panel's font menu +- `type.recentFonts`: how many recently used fonts head the font menu +- `type.fontNamesInEnglish`: off, fonts with a native-language family name show it +- `import.reportMissingFrames`: gaps in an image sequence's numbering are reported on import +- `import.unlabeledAlpha`: alpha of TGA / TIFF / movie footage (Ask opens Interpret Footage; + Guess takes premultiplied for movies, straight for stills) +- `import.dragImportAs`: layered files dropped on the window import as footage or a comp +- `export.defaultOutputFolder`: where relative Render Queue outputs go +- `export.segmentSequences`, `export.segmentSequenceFiles`: sequences split into numbered + folders of N files; `export.segmentMovies`, `export.segmentMovieMb`: movies split into + numbered files of about N MB (cut at the frame count the output's data rate gives) +- `export.appendBitsToName`: `Comp 1.png` → `Comp 1_16bpc.png` +- `audio.previewSampleRate`: the rate previews are mixed at (and the device is opened at, when + it supports it) +- `disk.diskCacheEnabled`, `disk.diskCacheMaxGb`, `disk.diskCacheFolder`: the persistent disk + cache of processed layers +- `disk.mediaCacheFolder`: audio waveform summaries kept between sessions (`Peaks/`) +- `disk.conformedMediaFolder`: decoded (conformed) footage audio, written once per file and + sample rate and read back instead of decoding again +- `memory.ramReservedGb`: the cache budgets together leave this much physical memory free +- `memory.reduceCacheWhenLow`: cache budgets halve while the system is low on memory (checked + every 10 s by the desktop app) +- `video.enableOutput`, `video.device`, `video.outputDuringPlayback`, `video.mirrorOnMonitor`, + `video.disableWhenBackground`: Video Preview, a second window (or full screen on the display + it is on) showing the composition frame, Mercury Transmit-style +- `threeD.showReferenceAxes`: world X/Y/Z axes in the viewer corner of comps with 3D layers +- `threeD.extendedViewer`: 3D layers reaching beyond the comp frame are outlined on the + pasteboard +- `threeD.realtimeShadows`: draft (Fast Previews) renders keep 3D shadows +- `scripting.allowScriptsWriteFiles`: the scripting file / network gate (and `File.execute()`) +- `scripting.warnExecutingFiles`: `File.execute()` asks before opening a file +- `scripting.editorFontSize`, `scripting.syntaxHighlighting`, `scripting.lineNumbers`, + `scripting.autoComplete` (Tab accepts), `scripting.bracketMatching`, `scripting.wordWrap`: + the Timeline's expression editor +- `scripting.errorBanner`: a banner along the bottom of the Composition panel names the first + failing expression (click: reveal it) -### Not wired yet (TODO) +### Display-only settings -These rows are in the dialog for After Effects parity and are stored, but nothing reads them yet. -`prefs.pages` reports them with `"live": false`. A test keeps this list in step with the schema. +These rows are in the dialog for After Effects parity and are stored, but have no EffectCraft +behaviour to change. `prefs.pages` reports them with `"live": false`; a test keeps this list in +step with the schema. -- `general.switchesAffectNestedComps`: Switches Affect Nested Comps -- `general.preserveConstantVertexCount`: Preserve Constant Vertex and Feather Point Count when Editing Masks -- `general.syncTimeRelatedItems`: Synchronize Time of All Related Items -- `general.expressionPickWhipCompact`: Expression Pick Whip Writes Compact English -- `general.createSplitLayersAbove`: Create Split Layers Above Original Layer -- `general.useSystemColorPicker`: Use System Color Picker -- `startup.showHomeOnOpenProject`: Show Home Screen When Opening a Project -- `composition.motionPath`: Motion Path -- `composition.motionPathSeconds`: Seconds -- `composition.motionPathKeyframes`: Keyframes -- `composition.hardwareAcceleratePanels`: Hardware Accelerate Composition, Layer and Footage Panels -- `previews.showInternalWireframes`: Show Internal Wireframes -- `previews.zoomQuality`: Viewer Zoom Quality -- `previews.displayProfile`: Display Color Space -- `appearance.useLabelColorForTabs`: Use Label Color for Related Tabs -- `appearance.cycleMaskColors`: Cycle Mask Colors -- `appearance.useGradients`: Use Gradients -- `grids.gridStyle`: Style -- `grids.proportionalHorizontal`: Horizontal -- `grids.proportionalVertical`: Vertical -- `grids.guideStyle`: Style -- `type.textEngine`: Text Engine -- `type.fontPreview`: Show Font Preview -- `type.recentFonts`: Number of Recent Fonts to Display -- `type.fontNamesInEnglish`: Show Font Names in English -- `import.reportMissingFrames`: Report Missing Frames -- `import.unlabeledAlpha`: Interpret Unlabeled Alpha As -- `import.dragImportAs`: Default Drag Import As -- `export.defaultOutputFolder`: Default Output Folder -- `export.segmentSequences`: Segment Sequences -- `export.segmentSequenceFiles`: Files per Segment -- `export.segmentMovies`: Segment Movie Files -- `export.segmentMovieMb`: Segment Size -- `export.appendBitsToName`: Append Bit Depth to File Name -- `audio.previewSampleRate`: Preview Sample Rate -- `disk.diskCacheEnabled`: Enable Disk Cache -- `disk.diskCacheMaxGb`: Maximum Disk Cache Size -- `disk.diskCacheFolder`: Disk Cache Folder -- `disk.mediaCacheFolder`: Database and Cache Folder -- `disk.conformedMediaFolder`: Conformed Audio Folder -- `memory.ramReservedGb`: RAM Reserved for Other Applications -- `memory.reduceCacheWhenLow`: Reduce Cache Size When System Is Low on Memory -- `video.enableOutput`: Enable Video Preview Output -- `video.device`: Video Device -- `video.outputDuringPlayback`: Video Output During Playback -- `video.mirrorOnMonitor`: Mirror on Computer Monitor -- `video.disableWhenBackground`: Disable Video Output When in Background -- `threeD.showReferenceAxes`: Show 3D Reference Axes -- `threeD.extendedViewer`: Extended Viewer -- `threeD.realtimeShadows`: Realtime Shadows in Draft -- `scripting.allowScriptsWriteFiles`: Allow Scripts to Write Files and Access Network -- `scripting.warnExecutingFiles`: Warn User When Executing Files -- `scripting.enableJsDebugger`: Enable JavaScript Debugger -- `scripting.editorFontSize`: Font Size -- `scripting.syntaxHighlighting`: Syntax Highlighting -- `scripting.lineNumbers`: Line Numbers -- `scripting.autoComplete`: Auto-complete -- `scripting.bracketMatching`: Bracket Matching -- `scripting.wordWrap`: Word Wrap -- `scripting.errorBanner`: Show Expression Error Banner +- `general.useSystemColorPicker`: EffectCraft has one colour picker on every platform and the + web; there is no pure-Rust way to open the system colour panels. +- `composition.hardwareAcceleratePanels`: the Composition, Layer and Footage panels are always + drawn by the GPU (egui on wgpu). +- `scripting.enableJsDebugger`: the JavaScript engine has no step debugger; script errors report + their file and line in the Script Console. ## Keyboard shortcuts