diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f34ce2764..74d946f01 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,22 @@ work apply to everyone, people and AI agents alike, and they are not negotiable. You need [Rust](https://rustup.rs/) 1.95 or newer. +On Linux the desktop app plays preview sound through `cpal`, so the build needs a usable ALSA +pkg-config package: an `alsa.pc` that pkg-config can find, naming the linkable ALSA runtime +library (usually `libasound.so.2`). `alsa-sys` runs pkg-config at build time; it does not compile +ALSA's headers. Without a usable package the build fails at link time with an unrelated-looking +error; `cargo xtask ci` probes pkg-config up front and names the package: + +```sh +sudo dnf install alsa-lib-devel # Fedora +sudo apt install libasound2-dev # Debian / Ubuntu +sudo pacman -S alsa-lib # Arch +``` + +If you cannot install the package, point `PKG_CONFIG_PATH` at a directory holding an `alsa.pc` +whose `Libs` line names your system's `libasound` (usually `libasound.so.2`); every CI run needs +that variable set. + ```sh cargo run -p effectcraft -- --demo # the desktop app, with the demo project cargo test --workspace diff --git a/Cargo.lock b/Cargo.lock index 627da7cf4..79ceb885c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7246,6 +7246,7 @@ name = "xtask" version = "0.18.0" dependencies = [ "chrono", + "pkg-config", "serde_json", "tempfile", ] diff --git a/apps/effectcraft-web/Cargo.toml b/apps/effectcraft-web/Cargo.toml index 28ea2f369..7743347af 100644 --- a/apps/effectcraft-web/Cargo.toml +++ b/apps/effectcraft-web/Cargo.toml @@ -37,6 +37,8 @@ js-sys = "0.3" web-sys = { version = "0.3", features = [ "Blob", "BlobPropertyBag", + "AbortController", + "AbortSignal", "CssStyleDeclaration", "Document", "Element", @@ -51,6 +53,12 @@ web-sys = { version = "0.3", features = [ "HtmlInputElement", "Location", "Navigator", + "ReadableStream", + "ReadableStreamDefaultReader", + "Request", + "RequestCache", + "RequestInit", + "Response", "Url", "UrlSearchParams", "Window", diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 1dcaf1334..60ff2f8d3 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -6,7 +6,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use effectcraft_engine::Session; -use crate::{api, audio, browse, diskcache, files, frames, persist, storage, worker}; +use crate::startup::Startup; +use crate::{api, audio, browse, config_file, diskcache, files, frames, persist, storage, worker}; use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; use wasm_bindgen::prelude::*; @@ -83,8 +84,12 @@ pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec) { /// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties. pub struct WebApp { pub app: EffectcraftApp, - /// Keep the session snapshot (off with `?empty`). + /// Keep the session snapshot (off with `?empty`; off with an explicit `"demo"` startup until + /// the user's own work begins, see `demo_start`). pub persist: bool, + /// Started on the demo by the config: the snapshot stays off, so the demo never replaces the + /// last visit's project, until the user opens a project from a file or edits the demo. + pub demo_start: bool, } impl eframe::App for WebApp { @@ -117,6 +122,11 @@ impl eframe::App for WebApp { api::poll_replies(); // Render Queue outputs written this frame (here or by a worker) → downloads. files::flush_downloads(); + // The user's own work after a demo start is worth keeping too: turn the snapshot on. + if self.demo_start && (self.app.session.path.is_some() || self.app.session.is_dirty()) { + self.demo_start = false; + self.persist = true; + } // What a reload comes back to. if self.persist { // The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick. @@ -223,6 +233,11 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { }; api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0})); audio::install_unlock(); + // `./effectcraft-config.json`: what to open on load. + // `?empty` and `?demo` decide by themselves, so they skip the request. + let startup = if empty || query_flag("demo") { Startup::default() } else { config_file::startup().await }; + // An explicit `"demo"` start leaves the saved session alone until the user opens or edits something. + let keep_session = !empty && startup.keeps_session(); browse::restore(); diskcache::init(); storage::refresh(); @@ -248,7 +263,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { // The canvas can't present any more (#225): the page says so over it and // offers a reload, which brings the session back when browser storage keeps it. let lost = gpu_failures.clone(); - let kept = !empty && persist::backend() != "memory"; + let kept = keep_session && persist::backend() != "memory"; rs.device.set_device_lost_callback(move |reason, message| { let message = format!("GPU device lost ({reason:?}): {message}"); lost.report(&message, true); @@ -258,10 +273,11 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { let mut session = session(); session.load_settings(); let recovery = session.begin_recovery(); - // `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's - // project (the demo on a first visit). - let restored = !empty && !query_flag("demo") && persist::restore(&mut session); - if !restored && !empty { + // `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's + // `startup` says (default: the last visit's project, the demo on a first visit). + let demo = query_flag("demo"); + let restored = !empty && !demo && startup.restores() && persist::restore(&mut session); + if !restored && !empty && (demo || startup.demo_when_nothing_restored()) { let _ = session.execute("file.openDemoProject", json!({})); } api::set_info("restored", json!(restored)); @@ -287,7 +303,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { app = app.with_control(rx); let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend)); api::set_info("backend", json!(backend)); - Ok(Box::new(WebApp { app, persist: !empty })) + Ok(Box::new(WebApp { app, persist: keep_session, demo_start: !empty && !keep_session })) }), ) .await?; diff --git a/apps/effectcraft-web/src/config_file.rs b/apps/effectcraft-web/src/config_file.rs new file mode 100644 index 000000000..614d7d4cb --- /dev/null +++ b/apps/effectcraft-web/src/config_file.rs @@ -0,0 +1,73 @@ +//! `./effectcraft-config.json`, fetched once per page load and shared by whoever reads a key. + +use std::cell::RefCell; + +use wasm_bindgen::{JsCast, closure::Closure}; +use wasm_bindgen_futures::JsFuture; + +use crate::startup::{self, Startup}; + +/// Longest the request and the body read may take (ms); a stalled server must not hold up start-up. +const TIMEOUT_MS: i32 = 5_000; + +thread_local! { + /// `None`: not fetched yet; `Some(None)`: no usable file. + static FILE: RefCell>>> = const { RefCell::new(None) }; +} + +/// The config file's bytes, or `None` when it is missing, too large, unreadable or the request +/// takes longer than [`TIMEOUT_MS`] (the timer aborts the request and covers the body read). +async fn fetch() -> Option> { + let win = web_sys::window()?; + let ctl = web_sys::AbortController::new().ok()?; + let init = web_sys::RequestInit::new(); + // Always ask the server: the file is the operator's switch, never a cached copy. + init.set_cache(web_sys::RequestCache::NoCache); + init.set_signal(Some(&ctl.signal())); + let on_timeout = Closure::once(move || ctl.abort()); + let timer = win.set_timeout_with_callback_and_timeout_and_arguments_0(on_timeout.as_ref().unchecked_ref(), TIMEOUT_MS).ok()?; + let out = read(&win, &init).await; + win.clear_timeout_with_handle(timer); + out +} + +/// Fetch the file and read its body chunk by chunk, giving up as soon as it passes `CONFIG_MAX`. +async fn read(win: &web_sys::Window, init: &web_sys::RequestInit) -> Option> { + let req = web_sys::Request::new_with_str_and_init("effectcraft-config.json", init).ok()?; + let resp: web_sys::Response = JsFuture::from(win.fetch_with_request(&req)).await.ok()?.dyn_into().ok()?; + if resp.status() != 200 { + return None; + } + let reader: web_sys::ReadableStreamDefaultReader = resp.body()?.get_reader().unchecked_into(); + let mut out = Vec::new(); + loop { + let chunk = JsFuture::from(reader.read()).await.ok()?; + if js_sys::Reflect::get(&chunk, &"done".into()).ok()?.as_bool() == Some(true) { + return Some(out); + } + let bytes: js_sys::Uint8Array = js_sys::Reflect::get(&chunk, &"value".into()).ok()?.dyn_into().ok()?; + if out.len().saturating_add(bytes.length() as usize) > startup::CONFIG_MAX { + // Stop the download: the limit is enforced while reading, not after the whole body arrived. + let _ = reader.cancel(); + return None; + } + out.extend_from_slice(&bytes.to_vec()); + } +} + +/// The config file's bytes (`None` when there is none), fetched on the first call. +pub async fn bytes() -> Option> { + if let Some(done) = FILE.with(|f| f.borrow().clone()) { + return done; + } + let got = fetch().await; + FILE.with(|f| *f.borrow_mut() = Some(got.clone())); + got +} + +/// The configured startup behaviour. +pub async fn startup() -> Startup { + let s = bytes().await.map(|b| startup::parse(&b)).unwrap_or_default(); + log::info!("startup: {s:?}"); + s +} diff --git a/apps/effectcraft-web/src/lib.rs b/apps/effectcraft-web/src/lib.rs index 97d2e887f..e1bf85894 100644 --- a/apps/effectcraft-web/src/lib.rs +++ b/apps/effectcraft-web/src/lib.rs @@ -18,10 +18,11 @@ //! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown (`audio`) | //! | TCP control channel / MCP | `window.effectcraft` JavaScript API (`api`) | //! -//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only. +//! [`store`] and [`startup`] are portable (unit-tested natively); everything else is `wasm32`-only. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod startup; pub mod store; #[cfg(target_arch = "wasm32")] @@ -33,6 +34,8 @@ pub mod audio; #[cfg(target_arch = "wasm32")] pub mod browse; #[cfg(target_arch = "wasm32")] +pub mod config_file; +#[cfg(target_arch = "wasm32")] pub mod diskcache; #[cfg(target_arch = "wasm32")] pub mod files; diff --git a/apps/effectcraft-web/src/startup.rs b/apps/effectcraft-web/src/startup.rs new file mode 100644 index 000000000..4759096f3 --- /dev/null +++ b/apps/effectcraft-web/src/startup.rs @@ -0,0 +1,55 @@ +//! The `startup` key of `./effectcraft-config.json`: which project the page opens on load. +//! +//! | Value | On load | +//! |---|---| +//! | `"last"` (and absent) | the last visit's project; the demo project on a first visit | +//! | `"empty"` | the last visit's project; a blank project on a first visit, never the demo | +//! | `"demo"` | the demo project, whatever the last visit left | +//! +//! The `?empty` and `?demo` URL flags still win. Anything else (a missing key, another type, an +//! unknown value, a config that is not JSON) is the default, `"last"`. + +use serde_json::Value; + +/// What the page opens on load. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Startup { + #[default] + Last, + Empty, + Demo, +} + +/// The largest config file read (bytes). +pub const CONFIG_MAX: usize = 64 * 1024; + +/// The `startup` key of a config file's bytes. +pub fn parse(bytes: &[u8]) -> Startup { + if bytes.len() > CONFIG_MAX { + return Startup::default(); + } + let Ok(v) = serde_json::from_slice::(bytes) else { return Startup::default() }; + match v.get("startup").and_then(Value::as_str) { + Some("empty") => Startup::Empty, + Some("demo") => Startup::Demo, + _ => Startup::Last, + } +} + +impl Startup { + /// Whether the last visit's project is restored (off for the demo). + pub fn restores(self) -> bool { + self != Startup::Demo + } + + /// Whether the session snapshot keeps being saved (off for the demo, so it never replaces the + /// project the last visit left). + pub fn keeps_session(self) -> bool { + self != Startup::Demo + } + + /// Whether the demo project opens when nothing was restored (off for a blank start). + pub fn demo_when_nothing_restored(self) -> bool { + self != Startup::Empty + } +} diff --git a/apps/effectcraft-web/tests/it/main.rs b/apps/effectcraft-web/tests/it/main.rs new file mode 100644 index 000000000..a4d242ba5 --- /dev/null +++ b/apps/effectcraft-web/tests/it/main.rs @@ -0,0 +1 @@ +mod startup_tests; diff --git a/apps/effectcraft-web/tests/it/startup_tests.rs b/apps/effectcraft-web/tests/it/startup_tests.rs new file mode 100644 index 000000000..3ce90569c --- /dev/null +++ b/apps/effectcraft-web/tests/it/startup_tests.rs @@ -0,0 +1,52 @@ +//! The `startup` key of the config file. + +use effectcraft_web::startup::{CONFIG_MAX, Startup, parse}; + +/// The three accepted values parse. +#[test] +fn the_three_values() { + assert_eq!(parse(br#"{"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo"}"#), Startup::Demo); + assert_eq!(parse(br#"{"startup":"last"}"#), Startup::Last); +} + +/// A missing key, a wrong type, an unknown value or a non-object file gives `Last`. +#[test] +fn absent_or_invalid_is_the_default() { + for bytes in + [&b"{}"[..], b"", b"not json", b"[]", b"null", br#"{"startup":1}"#, br#"{"startup":"Empty"}"#, br#"{"startup":null}"#, br#"{"startup":["empty"]}"#] + { + assert_eq!(parse(bytes), Startup::Last, "{:?}", String::from_utf8_lossy(bytes)); + } +} + +/// Other keys do not hide `startup`. +#[test] +fn a_config_with_other_keys_still_has_its_startup() { + assert_eq!(parse(br#"{"remote":{"mounts":[]},"startup":"empty"}"#), Startup::Empty); + assert_eq!(parse(br#"{"startup":"demo","unknown":{"a":1}}"#), Startup::Demo); +} + +/// A file over `CONFIG_MAX` is ignored. +#[test] +fn an_oversized_file_is_the_default() { + let mut big = br#"{"startup":"empty","pad":""#.to_vec(); + big.extend(std::iter::repeat_n(b'x', CONFIG_MAX)); + big.extend_from_slice(br#""}"#); + assert_eq!(parse(&big), Startup::Last); +} + +/// Which project each value restores or opens. +#[test] +fn what_each_value_does_on_load() { + assert!(Startup::Last.restores() && Startup::Last.demo_when_nothing_restored()); + assert!(Startup::Empty.restores() && !Startup::Empty.demo_when_nothing_restored()); + assert!(!Startup::Demo.restores() && Startup::Demo.demo_when_nothing_restored()); +} + +/// Only the explicit demo start stops saving the session. +#[test] +fn only_the_demo_start_leaves_the_saved_session_alone() { + assert!(Startup::Last.keeps_session() && Startup::Empty.keeps_session()); + assert!(!Startup::Demo.keeps_session()); +} diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js index 4acbeeeeb..8f48a93bc 100644 --- a/apps/effectcraft-web/web/sw.js +++ b/apps/effectcraft-web/web/sw.js @@ -3,7 +3,8 @@ // `cargo xtask web` fills in the build's file list and version. Every file is precached into a // cache named after the version, served cache-first (responses keep the server's headers, so // the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new -// one activates. Anything else same-origin is fetched from the network and cached for next time. +// one activates. Anything else same-origin is fetched from the network and cached for next time, +// except the config file, which must stay live. const VERSION = "__EC_VERSION__"; const FILES = __EC_FILES__; const CACHE = `effectcraft-${VERSION}`; @@ -30,6 +31,7 @@ self.addEventListener("fetch", (event) => { const req = event.request; const url = new URL(req.url); if (req.method !== "GET" || url.origin !== self.location.origin) return; + if (url.pathname.endsWith("/effectcraft-config.json")) return; event.respondWith( (async () => { const cache = await caches.open(CACHE).catch(() => null); diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs index da78ae116..d5de8f687 100644 --- a/crates/automation/tests/qa/compositing.rs +++ b/crates/automation/tests/qa/compositing.rs @@ -35,7 +35,7 @@ fn import_footage(qa: &mut Qa) -> (Value, Value) { // Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸ // Photoshop Layers) from a small comp. let seq = write_sequence(qa); - let imp = qa.exec("file.import", json!({"paths": [seq]})); + let imp = qa.exec("file.import", json!({"paths": [seq], "sequence": true})); let seq_item = imp["items"][0].clone(); let project = qa.tool("get_project", json!({})); let it = project["items"].as_array().unwrap().iter().find(|i| i["id"] == seq_item).unwrap().clone(); diff --git a/crates/automation/tests/qa/tracking.rs b/crates/automation/tests/qa/tracking.rs index 837872658..ee6bbb2e9 100644 --- a/crates/automation/tests/qa/tracking.rs +++ b/crates/automation/tests/qa/tracking.rs @@ -52,7 +52,7 @@ fn shake_offset(f: u32) -> (i64, i64) { /// Import a sequence at 12 fps and make a comp of it; returns (comp, layer). fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) { - let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone(); + let item = qa.exec("file.import", json!({"paths": [first], "sequence": true}))["items"][0].clone(); qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12})); let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone(); let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone(); diff --git a/crates/engine/presets/text_animators.json b/crates/engine/presets/text_animators.json index 20e142268..e82707a52 100644 --- a/crates/engine/presets/text_animators.json +++ b/crates/engine/presets/text_animators.json @@ -77,7 +77,7 @@ "name": "Tracking In", "properties": { "tracking": 0, "opacity": 100 }, "keys": { - "tracking": [[0, 400], [1.6, 0]], + "tracking": [[0, 40], [1.6, 0]], "opacity": [[0, 0], [0.8, 100]] }, "ease": true, diff --git a/crates/engine/src/cmd/app_more.rs b/crates/engine/src/cmd/app_more.rs index f607c8288..5bd897ba9 100644 --- a/crates/engine/src/cmd/app_more.rs +++ b/crates/engine/src/cmd/app_more.rs @@ -152,7 +152,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result { 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_file_json_with_view(&s.saved_view())?).map_err(EngineError::Other)?; + let xml = crate::xml_project::to_xml(&s.project.to_file_json_with_view(&s.saved_view(), false)?).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()})) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bfc05933e..ab847ab87 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -74,9 +74,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result { Ok(json!(s.state.selected_layers.len())) } fn deselect_all(s: &mut Session, _: &Value) -> Result { - // A deselected layer never keeps an open text edit: commit the edit first, exactly as - // `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut, - // script, MCP — leaves one final state. + // A deselected layer never keeps an open text edit: commit it first, exactly as `text.endEdit`, + // so every route — menu, shortcut, script, MCP — leaves one final state. An empty Type-tool + // layer survives, as it does in After Effects. super::text_edit::end_edit(s, &Value::Null)?; s.state.selected_layers.clear(); s.state.selected_props.clear(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 194845803..beb161a53 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -87,7 +87,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { /// project opened from an XML copy saves as XML again). Fails, writing nothing, when the project /// wouldn't read back. pub(crate) fn file_text(s: &Session, path: &str) -> Result { - let json = s.project.to_file_json_with_view(&s.saved_view())?; + let compact = s.prefs.project.compact_project_files && !crate::xml_project::is_xml_path(path); + let json = s.project.to_file_json_with_view(&s.saved_view(), compact)?; if crate::xml_project::is_xml_path(path) { crate::xml_project::to_xml(&json).map_err(EngineError::Other) } else { Ok(json) } } diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 7962c2485..ccc4810a9 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -161,7 +161,7 @@ pub(super) fn text_range_p(p: &Value) -> Option> { /// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40 /// characters of the first text line, else "Text". `edit: true` also opens a new editing /// session with anchor `0` and the caret after the text, selecting all initial text so the next -/// insertion replaces it. The session has `created: true`, so leaving it empty removes the layer. +/// insertion replaces it. The session has `created: true`; leaving it empty keeps the layer, as After Effects does. pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); diff --git a/crates/engine/src/cmd/text_edit.rs b/crates/engine/src/cmd/text_edit.rs index 0d9b25aec..cdb650486 100644 --- a/crates/engine/src/cmd/text_edit.rs +++ b/crates/engine/src/cmd/text_edit.rs @@ -71,7 +71,8 @@ pub struct TextEdit { /// Character panel changes made with only a caret: the style the next typed text takes. #[serde(default)] pub pending: Option, - /// The layer was made by a Type tool click; leaving it empty deletes it. + /// The layer was made by a Type tool click. Kept when left empty, as After Effects does, so this + /// only records how the edit began. #[serde(default)] pub created: bool, /// Line position kept by repeated up / down moves (layout space). @@ -372,18 +373,14 @@ fn edit(s: &mut Session, p: &Value) -> Result { Ok(state_json(s)) } -/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted). +/// `text.endEdit`: stop editing. +/// +/// A Type-tool layer the user clicked away from without typing is **kept**: After Effects leaves it in +/// the timeline as `<空白のテキストレイヤー>`, and the layer came from the user's click rather than from a +/// mistake to clean up. Deleting it took away something they could still type into. pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result { - let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) }; - if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) { - let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; - s.edit("Delete Empty Text Layer", None, |proj, st| { - if let Some(c) = proj.comp_mut(cid) { - c.layers.retain(|l| l.id != e.layer); - } - st.selected_layers.retain(|l| *l != e.layer); - Ok(()) - })?; + if s.state.text_edit.take().is_none() { + return Ok(Value::Null); } s.bump(); Ok(Value::Null) diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index e3f2006d8..d330ce190 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -122,6 +122,11 @@ pub fn pages() -> Vec { Section("New Project"), s("project.useTemplate", "New Project Loads Template", B, true), s("project.templatePath", "Template Project", Kind::Path, true), + Section("Project Files"), + s("project.compactProjectFiles", "Save Compact Project Files", B, true), + Note( + "Saves .ecproj files as compact JSON on one line: smaller and faster to save and open, but hard to read or diff in version control. Off, they are indented, one value per line.", + ), Section("Auto-Save"), s("autoSave.enabled", "Automatically Save Projects", B, true), s("autoSave.intervalMinutes", "Save Every", Kind::Int(1, 240, "Minutes"), true), diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index e0bea8b85..aa683e2b8 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -62,6 +62,9 @@ page!(ProjectPrefs { /// Open a template project for File ▸ New ▸ New Project. use_template: bool = false, template_path: String = String::new(), + /// Write `.ecproj` files as compact JSON on one line (smaller, faster to save and open). Off: + /// indented, one value per line, so they read and diff cleanly in version control. + compact_project_files: bool = false, }); page!(AutoSave { diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index 81bed3c43..03003a77b 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -389,6 +389,23 @@ fn new_project_template_and_default_renderer() { let _ = std::fs::remove_dir_all(d); } +#[test] +fn project_files_are_indented_unless_compact_is_on() { + let d = tmp("compact-project-files"); + let path = d.join("p.ecproj"); + let mut s = session_with_comp(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let pretty = std::fs::read_to_string(&path).unwrap(); + assert!(pretty.starts_with("{\n \"savedBy\""), "indented by default"); + s.execute("prefs.set", json!({"key": "project.compactProjectFiles", "value": true})).unwrap(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let compact = std::fs::read_to_string(&path).unwrap(); + assert!(!compact.contains('\n'), "one line when Save Compact Project Files is on"); + let open = |text: &str| effectcraft_project::Project::from_json(text).unwrap(); + assert_eq!(open(&compact), open(&pretty), "both read back as the same project"); + let _ = std::fs::remove_dir_all(d); +} + // ---------------------------------------------------------------- shortcuts #[test] diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index 17d0345d8..c48cff7e3 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -260,25 +260,25 @@ fn clipboard_and_paste_formatting() { } #[test] -fn type_tool_layers_point_box_and_empty_cleanup() { +fn type_tool_layers_point_box_and_empty_layer_is_kept() { let mut s = Session::default(); s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap(); let t = s.execute("layer.newText", json!({"text": "", "position": [100, 200], "justify": "left", "edit": true})).unwrap()["layer"].as_u64().unwrap(); assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t)); - // Leaving an untouched Type-tool layer deletes it. + // Leaving an untouched Type-tool layer keeps it, as After Effects does. s.execute("text.endEdit", json!({})).unwrap(); - assert!(s.active_comp().unwrap().layers.is_empty()); + assert_eq!(s.active_comp().unwrap().layers.len(), 1, "an empty Type-tool layer is kept, as in AE"); let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap(); s.execute("text.insert", json!({"text": "Boxed"})).unwrap(); let d = doc(&s, b); assert_eq!(d.box_size, Some([300.0, 120.0])); assert_eq!(d.box_pos, [-150.0, -60.0]); - let l = s.active_comp().unwrap().layers[0].clone(); + let l = s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == b).expect("the boxed layer").clone(); assert_eq!(l.name, "Boxed", "named after its text"); let pos = l.props.prop("transform/position").unwrap().value.as_vec3(); assert_eq!([pos[0], pos[1]], [190.0, 110.0]); s.execute("text.endEdit", json!({})).unwrap(); - assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(s.active_comp().unwrap().layers.len(), 2, "both text layers stay, the empty one included"); // Resizing the box (layer space) is an attribute too. s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap(); assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0])); @@ -1202,7 +1202,7 @@ fn deselect_all_ends_the_open_edit() { } #[test] -fn deselect_all_discards_an_empty_type_tool_layer() { +fn deselect_all_keeps_an_empty_type_tool_layer() { let (mut s, _keep) = session("Hello"); let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap(); assert!( @@ -1214,7 +1214,5 @@ fn deselect_all_discards_an_empty_type_tool_layer() { s.execute("edit.deselectAll", json!({})).unwrap(); assert!(s.state.text_edit.is_none()); assert!(s.state.selected_layers.is_empty()); - assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does"); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit"); + assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects"); } diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index 22759e27d..b2b64ed2c 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -267,10 +267,11 @@ impl Project { /// (NaN or infinity, written as `null`, and nesting too deep to parse) are refused on every /// save; debug builds also read the text back. pub fn to_file_json(&self) -> Result { - self.to_file_json_with_view(&SavedView::default()) + self.to_file_json_with_view(&SavedView::default(), false) } - /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]). - pub fn to_file_json_with_view(&self, view: &SavedView) -> Result { + /// [`Project::to_file_json`] that also records the open compositions ([`SavedView`]), on one + /// line when `compact` (Settings ▸ Project ▸ Save Compact Project Files). + pub fn to_file_json_with_view(&self, view: &SavedView, compact: bool) -> Result { if self.schema > SCHEMA_VERSION { return Err(ProjectError::Invalid(format!( "the project can't be saved: it holds a value a project file can't store (project schema {} is newer than this version supports ({SCHEMA_VERSION}))", @@ -289,7 +290,8 @@ impl Project { let file = File { saved_by: APP_VERSION, view, project: self }; file_check::check(&file) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; - let text = serde_json::to_string_pretty(&file).map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; + let text = if compact { serde_json::to_string(&file) } else { serde_json::to_string_pretty(&file) } + .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: {e}")))?; #[cfg(debug_assertions)] Project::from_json(&text) .map_err(|e| ProjectError::Invalid(format!("the project can't be saved: it holds a value a project file can't store ({e})")))?; diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index f9ee16167..c0bdb012f 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -183,7 +183,7 @@ fn project_files_refuse_newer_schemas() { crate::SCHEMA_VERSION ); assert_eq!(p.to_file_json().unwrap_err().to_string(), expected); - assert_eq!(p.to_file_json_with_view(&crate::SavedView::default()).unwrap_err().to_string(), expected); + assert_eq!(p.to_file_json_with_view(&crate::SavedView::default(), false).unwrap_err().to_string(), expected); } #[test] @@ -197,7 +197,7 @@ fn saved_files_reopen_in_pretty_and_compact_form() { fn project_files_remember_the_open_comps() { let (p, cid) = project_with_layer(); let view = crate::SavedView { active_comp: Some(cid), open_comps: vec![cid], comp_times: [(cid, effectcraft_time::Tick(4_237_833_600))].into() }; - let text = p.to_file_json_with_view(&view).unwrap(); + let text = p.to_file_json_with_view(&view, false).unwrap(); assert_eq!(crate::saved_view(&text), view); assert_eq!(Project::from_json(&text).unwrap(), p, "the view is not part of the project"); // Files from before #748 have no view; a garbled one is ignored, not an error. diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 132da9bf6..c25b1da8d 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -42,7 +42,8 @@ pub struct CharXf { pub stroke_hsb: [f64; 3], pub stroke_opacity: f64, pub stroke_width: f64, - /// Tracking (pixels) added before / after the character. + /// Tracking (pixels: an animator's Tracking Amount, as After Effects) added before / after + /// the character. pub track_before: f64, pub track_after: f64, /// Line Anchor (percent) and the selection weight it was set with. @@ -694,6 +695,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0); for gi in li_start..li_end { let x = &xfs[gi]; + // An animator's Tracking Amount is in pixels, whatever the font size (After Effects + // 2026: Amount 30 adds 30 px between selected characters at 80 px and at 40 px), unlike + // the Character panel's tracking in 1/1000 em. pen += x.track_before; shift[gi] = pen; pen += x.track_after + vadv[gi]; @@ -804,9 +808,12 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option { }; // Outline relative to the pivot. let local = kurbo::Affine::translate((o[0] - pivot[0], o[1] - pivot[1])) * outline; - let char_m = Mat4::rotate_z(x.rotation) + // Rx · Ry · Rz (Z turns first): the order After Effects gives a 3D layer's X / Y / Z + // Rotation too. #1876 moves layers to it with a helper, `Mat4::rotation_xyz`; once both + // land, this should call that helper (one convention, one place). + let char_m = Mat4::rotate_x(x.rotation_x) * Mat4::rotate_y(x.rotation_y) - * Mat4::rotate_x(x.rotation_x) + * Mat4::rotate_z(x.rotation) * Mat4::from_mat3_affine(&Mat3::skew_deg(-x.skew, x.skew_axis)) * Mat4::scale(Vec3::from(x.scale) / 100.0) * Mat4::translate(-Vec3::from(x.anchor)); diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 43f53dce9..4bf459142 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -222,7 +222,7 @@ fn tracking_line_anchor_and_character_offset() { let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(20.0)); let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])); - // 4 chars with 20 px tracking: the last glyph moves ~ 3.5 × 20 px further (before & after). + // Tracking Amount 20 (pixels, as After Effects): the last glyph moves ~ 3.5 × 20 px further (before & after). assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width()); assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0); // Line Anchor 100%: tracking grows to the left. @@ -262,7 +262,7 @@ fn tracking_grows_from_each_paragraphs_alignment() { p.comp_mut(cid).unwrap().layers.push(l); glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]) }; - // 4 chars with 20 px of tracking each, half before and half after. + // Tracking Amount 20 (pixels): 20 px of tracking each, half before and half after. for (justify, x0, centre, x1) in [(Justify::Left, 10.0, 40.0, 70.0), (Justify::Center, -30.0, 0.0, 30.0), (Justify::Right, -70.0, -40.0, -10.0)] { let d = TextDoc { justify, ..doc("ABCD", 40.0) }; let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 20.0))); @@ -504,6 +504,37 @@ fn per_character_3d_projects_characters() { assert!(b[2] - b[0] < flat[2] - flat[0] && b[2] > b[0] + 10, "{b:?} {flat:?}"); } +/// After Effects 2026 (case 931: "H", 400 px, per-character 3D, X / Y / Z Rotation 40 / 30 / +/// 20): a character turns like a 3D layer, Rx · Ry · Rz (Z first), so its matrix is the same +/// product as a layer's X / Y / Z Rotation. The render then matches AE (3 of 5184 grid points +/// off at edges; with Rz · Ry · Rx 23). +#[test] +fn per_character_rotation_turns_z_first_as_a_3d_layer() { + let (mut p, cid, comp) = setup(400, 300); + let mut l = text_layer(&mut p, &comp, doc("H", 80.0)); + l.switches.three_d = true; + animator(&mut p, &mut l, &["rotation"], &["range"]); + let mut next = p.next_id; + let mut extra = build::text_anim_props(&mut Ids(&mut next), "rotation", true); + p.next_id = next; + let props: &mut PropGroup = l.props.group_mut("text/animators/#1/properties").unwrap(); + props.children.retain(|c| c.match_id() != "rotation"); + props.children.extend(extra.drain(..).map(Into::into)); + for (id, v) in [("rotationX", 40.0), ("rotationY", 30.0), ("rotation", 20.0)] { + props.get_mut(id).unwrap().value = Value::Scalar(v); + } + l.props.sub_mut("text").unwrap().get_mut("perChar3d").unwrap().value = Value::Bool(true); + p.comp_mut(cid).unwrap().layers.push(l); + let geom = text_geom(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]).unwrap(); + let g = &geom.glyphs[0]; + let want = Mat4::rotate_x(40.0) * Mat4::rotate_y(30.0) * Mat4::rotate_z(20.0); + for r in 0..3 { + for c in 0..3 { + assert!((g.m.0[r][c] - want.0[r][c]).abs() < 1e-9, "row {r} col {c}: {:?} vs {:?}", g.m.0, want.0); + } + } +} + #[test] fn keyframed_selector_animates() { let (mut p, cid, comp) = setup(400, 200); diff --git a/crates/script/src/tests/ease_presets.rs b/crates/script/src/tests/ease_presets.rs index 009cc59c6..f075250b2 100644 --- a/crates/script/src/tests/ease_presets.rs +++ b/crates/script/src/tests/ease_presets.rs @@ -608,3 +608,37 @@ fn presets_saved_by_the_old_core_panel_carry_over() { let win = open_panel(&mut s); assert_eq!(control(&mut s, win, "presets")["items"].as_array().unwrap().len(), 12); } + +/// #2465: the Linear preset sets Linear interpolation on every side it applies to, including the +/// unused first-in and last-out sides; editing its speeds makes a Bezier curve. +#[test] +fn the_linear_preset_sets_linear_interpolation_and_edited_speeds_give_bezier() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + let win = open_panel(&mut s); + // Start from Bezier keys so Linear has something to restore. + ok( + &mut s, + r#"var p = app.project.activeItem.layer(1).transform.opacity, K = KeyframeInterpolationType; + for (var i = 1; i <= p.numKeys; i++) p.setInterpolationTypeAtKey(i, K.BEZIER, K.BEZIER);"#, + ); + let op = prop(&s, l, "transform/opacity"); + for (i, k) in op.keys.iter().enumerate() { + assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier), "initial key {i}"); + } + let steps = s.history.undo.len(); + set(&mut s, win, "presets", json!("Linear")); + assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); + let op = prop(&s, l, "transform/opacity"); + for (i, k) in op.keys.iter().enumerate() { + assert_eq!((k.in_interp, k.out_interp), (Interp::Linear, Interp::Linear), "key {i}"); + } + // Edited speeds make a custom Bezier curve even though Linear is still the selected preset. + select_keys(&mut s, l, &[("transform/opacity", 1.0)]); + apply_typed(&mut s, win, "In-Out", [33.0, 2.0, 33.0, 2.0]); + let k = &prop(&s, l, "transform/opacity").keys[1]; + assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier)); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 179478517..4c0b5a436 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -565,6 +565,34 @@ fn space_item(glyphs: &mut [ShapedGlyph], style: &TextStyle, text: &str, para_st } } +/// After Effects' first-baseline offset of paragraph (box) text for a face at nominal size +/// `px`: the height of its lowercase `d`, in whole thousandths of an em. Measured in After +/// Effects 2026 (case 818): the first baseline sits 69.50 px below the box top for Times New +/// Roman at 100 px (its `d` is 1422/2048 em, 694.3 thousandths, rounded 695) and 71.60 px for +/// Arial (1466/2048, 715.8, rounded 716), at 50 px half of that; superscript text keeps the +/// line where normal text puts it and only raises its glyphs. `None` without a `d` outline. +fn first_ascent(face: FaceId, px: f32) -> Option { + let f = fonts::face(face); + let gid = f.glyph('d')?; + let units = crate::outline_units(face, gid)?; + let top = kurbo::Shape::bounding_box(&*units).y1 as f32; + let em = (top / f.units_per_em() * 1000.0).round() / 1000.0; + plausible_first(em, px, f.metrics(px).ascent) +} + +/// A `d` at least this fraction of the font ascent is a Latin lowercase ascender; a shorter one +/// (a symbol or icon font's `d`) would put the first baseline almost at the box top, so the font +/// ascent is used instead (the rule before the `d` height). +const MIN_D_OF_ASCENT: f32 = 0.3; + +/// The first baseline `em * px` below the box top, if `em` (the `d` height in em) is plausible: +/// finite, positive, under 4 em and at least [`MIN_D_OF_ASCENT`] of the font `ascent` (px). +fn plausible_first(em: f32, px: f32, ascent: f32) -> Option { + let h = em * px; + let tall_enough = !(ascent.is_finite() && ascent > 0.0) || h >= MIN_D_OF_ASCENT * ascent; + (h.is_finite() && h > 0.0 && em < 4.0 && tall_enough).then_some(h) +} + struct ParaLine { range: Range, glyphs: Vec, @@ -575,6 +603,9 @@ struct ParaLine { hang: (f32, f32), ascent: f32, descent: f32, + /// Where a box's first line puts its baseline below the box top ([`first_ascent`] of the + /// line's styles at their nominal size; the font ascent where a face has no `d`). + first: f32, /// Line advance (largest leading on the line). advance: f32, align: Align, @@ -598,20 +629,25 @@ fn run_at(runs: &[(Range, TextStyle)], b: usize) -> usize { /// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string. /// Margins (left, right) of a paragraph's first or later lines. -fn margins_for(para: &ParagraphStyle, first: bool) -> (f32, f32) { +/// `rtl` is the resolved paragraph direction (an unset direction follows the text). +fn margins_for(para: &ParagraphStyle, rtl: bool, first: bool) -> (f32, f32) { let fi = if first { para.indent_first } else { 0.0 }; - if para.rtl == Some(true) { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) } + if rtl { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) } } /// The width a line may fill (`None` for point text). -fn avail_for(para: &ParagraphStyle, width: Option, first: bool) -> Option { - let (l, r) = margins_for(para, first); +fn avail_for(para: &ParagraphStyle, rtl: bool, width: Option, first: bool) -> Option { + let (l, r) = margins_for(para, rtl, first); width.map(|w| (w - l - r).max(1.0)) } /// The alignment of a line, justified lines that cannot stretch falling back to `justify_last`. -fn align_for(para: &ParagraphStyle, width: Option, last: bool) -> Align { - if (last || width.is_none()) && para.align == Align::Justify { +fn align_for(para: &ParagraphStyle, rtl: bool, width: Option, last: bool) -> Align { + if rtl && width.is_none() && para.align == Align::Justify { + // Right-to-left point text with a justified paragraph sits flush at its start, the + // right (After Effects 2026, every justify code; left-to-right not measured). + Align::Right + } else if (last || width.is_none()) && para.align == Align::Justify { if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last } } else { para.align @@ -622,6 +658,12 @@ fn style_metrics(primaries: &[Resolved], runs: &[(Range, TextStyle)], si: fonts::face(primaries[si].face).metrics(runs[si].1.glyph_size()) } +/// The baseline of a box's first line for style run `si`: the `d` height, else the font ascent. +fn style_first(primaries: &[Resolved], runs: &[(Range, TextStyle)], si: usize) -> f32 { + let st = &runs[si].1; + first_ascent(primaries[si].face, st.size).unwrap_or_else(|| fonts::face(primaries[si].face).metrics(st.size).ascent) +} + /// A char's shaping inputs: (byte, char, shaped char, face, rtl, form, run). type CharInfo = (usize, char, char, FaceId, bool, Form, usize); @@ -719,7 +761,7 @@ impl<'a> Para<'a> { } fn line_avail(&self, line: usize) -> f32 { - avail_for(self.para, self.width, line == 0).unwrap_or(f32::MAX) + avail_for(self.para, self.base_rtl, self.width, line == 0).unwrap_or(f32::MAX) } /// The byte ranges of the lines, for the wrap width. @@ -736,8 +778,11 @@ impl<'a> Para<'a> { ranges } - /// Minimises the summed squared slack of every line but the last. + /// Minimises the summed squared slack of every line but the last; with Justify All the last + /// line is stretched too, so its slack counts (After Effects then balances the lines: 4 / 4 / 4 + /// words where the other modes break 6 / 6 / 1). fn optimal_breaks(&self, opps: &[usize]) -> Vec> { + let last_counts = self.para.align == Align::Justify && self.para.justify_last == Align::Justify; let nodes: Vec = std::iter::once(0).chain(opps.iter().copied()).collect(); let m = nodes.len(); let mut best = vec![(f32::INFINITY, 0usize); m]; @@ -757,7 +802,7 @@ impl<'a> Para<'a> { let slack = maxw - w; let cost = if over { 1e12 + w - } else if j == m - 1 { + } else if j == m - 1 && !last_counts { 0.0 } else { slack * slack @@ -802,8 +847,8 @@ impl<'a> Para<'a> { /// The line over byte range `r`: the `li`-th of the paragraph, `last` if it is the final one. fn place_line(&self, li: usize, r: Range, last: bool) -> ParaLine { - let align = align_for(self.para, self.width, last); - let avail = avail_for(self.para, self.width, li == 0); + let align = align_for(self.para, self.base_rtl, self.width, last); + let avail = avail_for(self.para, self.base_rtl, self.width, li == 0); let (ce, tw) = self.trailing_ws(&r); let hang = self.hang_of(&r, ce); let content = self.range_w(r.start..ce); @@ -819,7 +864,7 @@ impl<'a> Para<'a> { } let placed = self.place_glyphs(&r, space_extra, ce); let carets = self.carets(&r, &placed.extents, placed.pen); - let (asc, desc, adv) = self.line_metrics(&r, &placed.glyphs); + let (asc, desc, adv, first) = self.line_metrics(&r, &placed.glyphs); let content_w = if space_extra > 0.0 { avail.map_or(content, |a| a + hang.0 + hang.1) } else { content }; ParaLine { range: self.base + r.start..self.base + r.end, @@ -830,10 +875,11 @@ impl<'a> Para<'a> { hang, ascent: asc, descent: desc, + first, advance: adv, align, avail, - margins: margins_for(self.para, li == 0), + margins: margins_for(self.para, self.base_rtl, li == 0), underline: placed.underline, } } @@ -938,8 +984,8 @@ impl<'a> Para<'a> { } /// Ascent, descent and advance of the line: the tallest face and the largest leading on it. - fn line_metrics(&self, r: &Range, glyphs: &[Glyph]) -> (f32, f32, f32) { - let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32); + fn line_metrics(&self, r: &Range, glyphs: &[Glyph]) -> (f32, f32, f32, f32) { + let (mut asc, mut desc, mut adv, mut first) = (0.0f32, 0.0f32, 0.0f32, 0.0f32); let char_start = self.chars.partition_point(|c| c.0 < r.start); let char_end = self.chars.partition_point(|c| c.0 < r.end); let mut seen: Vec = self.chars[char_start..char_end].iter().map(|c| c.6).collect(); @@ -954,6 +1000,7 @@ impl<'a> Para<'a> { asc = asc.max(m.ascent + rise.max(0.0)); desc = desc.max(m.descent - rise.min(0.0)); adv = adv.max(self.runs[si].1.line_advance()); + first = first.max(style_first(self.primaries, self.runs, si)); } for g in glyphs { if g.face != self.primaries[g.run].face { @@ -962,7 +1009,7 @@ impl<'a> Para<'a> { desc = desc.max(m.descent); } } - (asc, desc, adv) + (asc, desc, adv, first) } } @@ -1037,6 +1084,8 @@ fn paragraph( if text.is_empty() { let si = run_at(runs, base); let pm = style_metrics(primaries, runs, si); + // No text to resolve a direction from: the explicit one. + let rtl = para.rtl == Some(true); return vec![ParaLine { range: base..base, glyphs: vec![], @@ -1047,9 +1096,10 @@ fn paragraph( ascent: pm.ascent, descent: pm.descent, advance: runs[si].1.line_advance(), - align: align_for(para, width, true), - avail: avail_for(para, width, true), - margins: margins_for(para, true), + first: style_first(primaries, runs, si), + align: align_for(para, rtl, width, true), + avail: avail_for(para, rtl, width, true), + margins: margins_for(para, rtl, true), underline: None, }]; } @@ -1120,7 +1170,10 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para for (i, (pi, pl)) in plines.into_iter().enumerate() { let para = para_at(pi); if i == 0 { - baseline = if width.is_some() { pl.ascent } else { 0.0 }; + // Vertical type keeps its first column at the font ascent (not measured in AE). + let vertical = runs.get(run_at(runs, pl.range.start)).is_some_and(|r| r.1.vertical) + || runs.iter().any(|r| r.1.vertical && r.0.start < pl.range.end && r.0.end > pl.range.start); + baseline = if width.is_some() { if vertical { pl.ascent } else { pl.first } } else { 0.0 }; } else { let prev_para = lay.lines.last().map_or(pi, |l| l.para); let mut adv = pl.advance + para.leading; diff --git a/crates/text/src/layout/tests.rs b/crates/text/src/layout/tests.rs index 9079a1225..5792c1c16 100644 --- a/crates/text/src/layout/tests.rs +++ b/crates/text/src/layout/tests.rs @@ -357,3 +357,140 @@ fn named_instance_sets_the_base_variations() { let inter = layout_uncached("Hi", &TextStyle::default(), &ParagraphStyle::default()); assert!(inter.glyphs.iter().all(|g| g.variations == 0)); } + +/// After Effects 2026 (case 818): box text's first baseline is the `d` height (in whole +/// thousandths of an em) below the box top, at the nominal size also for superscript, whose +/// glyphs are then raised. EffectCraft used the font ascent of the drawn size (plus the +/// superscript rise): 89.1 px instead of 69.5 for Times New Roman at 100 px, and the +/// superscript glyphs 15.8 px low. +#[test] +fn box_text_first_baseline_is_the_d_height_as_after_effects() { + let boxed = ParagraphStyle { width: Some(600.0), ..Default::default() }; + // The rule on a bundled face (works everywhere). + for (family, size) in [("Inter", 100.0f32), ("Noto Serif", 50.0)] { + let s = TextStyle { family: family.into(), ..st(size) }; + let r = fonts::resolve(family, "Regular"); + let f = fonts::face(r.face); + let d = crate::outline_units(r.face, f.glyph('d').unwrap()).unwrap(); + let em = (kurbo::Shape::bounding_box(&*d).y1 as f32 / f.units_per_em() * 1000.0).round() / 1000.0; + let l = layout("Hxg", &s, &boxed); + assert!((l.lines[0].baseline - em * size).abs() < 1e-3, "{family}: {} vs {}", l.lines[0].baseline, em * size); + assert!(l.lines[0].baseline < l.lines[0].ascent, "{family}: below the full ascent"); + } + // After Effects' numbers, where the real fonts are installed. + for (family, ae_normal) in [("Times New Roman", 69.5008f32), ("Arial", 71.5988)] { + let r = fonts::resolve(family, "Regular"); + if fonts::face(r.face).info.family != family { + continue; + } + for (size, script, glyph_baseline) in + [(100.0, Script::Normal, ae_normal), (50.0, Script::Normal, ae_normal / 2.0), (100.0, Script::Super, ae_normal - 100.0 * SCRIPT_OFFSET)] + { + let s = TextStyle { family: family.into(), script, ..st(size) }; + let l = layout("Hxg", &s, &boxed); + let drawn = l.glyphs[0].y; + assert!((drawn - glyph_baseline).abs() < 0.15, "{family} {size} {script:?}: {drawn} vs AE {glyph_baseline}"); + } + } +} + +/// CodeRabbit on #1875: a face whose `d` is very short (a symbol or icon font) would put the +/// first baseline almost at the box top; below 0.3 of the ascent the font ascent is used. +#[test] +fn a_short_d_falls_back_to_the_font_ascent() { + // Latin faces: the `d` is about 0.7 em, the ascent about 0.9 em. + let near = |v: Option, want: f32| v.is_some_and(|v| (v - want).abs() < 1e-3); + assert!(near(plausible_first(0.7, 100.0, 90.0), 70.0)); + // A `d` a tenth of the ascent: not a lowercase ascender. + assert_eq!(plausible_first(0.09, 100.0, 90.0), None); + assert_eq!(plausible_first(0.26, 100.0, 90.0), None); + assert!(near(plausible_first(0.28, 100.0, 90.0), 28.0)); + // Broken values stay rejected; an unusable ascent does not reject a sane `d`. + for (em, px) in [(0.0, 100.0), (-0.5, 100.0), (f32::NAN, 100.0), (5.0, 100.0), (0.7, f32::INFINITY)] { + assert_eq!(plausible_first(em, px, 90.0), None, "{em} {px}"); + } + assert!(near(plausible_first(0.7, 100.0, f32::NAN), 70.0)); + // The bundled faces keep the `d` rule. + for family in ["Inter", "Noto Serif"] { + let r = fonts::resolve(family, "Regular"); + assert!(first_ascent(r.face, 100.0).is_some(), "{family}"); + } +} + +/// After Effects 2026 (EXPORTER t6): with Justify All and the every-line composer, a box that +/// the other justifications break into lines of 6, 6 and 1 words breaks 4 / 4 / 4, so the +/// stretched last line is not a lone word; word positions then match AE to 1 px. +#[test] +fn justify_all_balances_the_lines_with_the_every_line_composer() { + let text = "one two three four five six seven eight nine ten eleven twelve"; + let s = st(30.0); + let words = |l: &Layout| -> Vec { l.lines.iter().map(|ln| text[ln.range.clone()].split_whitespace().count()).collect() }; + let p = |last: Align| ParagraphStyle { width: Some(450.0), align: Align::Justify, justify_last: last, every_line: true, ..Default::default() }; + let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]); + let all = layout_rich_uncached(text, &[(0..text.len(), s)], &[p(Align::Justify)]); + let (wl, wa) = (words(&left), words(&all)); + assert!(wl.last().copied().unwrap_or(0) < *wl.first().unwrap_or(&0), "{wl:?}"); + let (lo, hi) = (wa.iter().min().copied().unwrap_or(0), wa.iter().max().copied().unwrap_or(0)); + assert!(hi - lo <= 1, "Justify All balances the lines: {wa:?} (left-justified: {wl:?})"); +} + +/// After Effects 2026 (EXPORTER read-back t7): right-to-left point text with any justified +/// code (justify last left / centre / right, justify all) sits flush right at its anchor, as +/// right-aligned text does. +#[test] +fn rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() { + let text = "Right to left"; + let s = st(30.0); + let at = |align: Align, last: Align| { + let p = ParagraphStyle { align, justify_last: last, rtl: Some(true), ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let right = at(Align::Right, Align::Left); + for last in [Align::Left, Align::Center, Align::Right, Align::Justify] { + let got = at(Align::Justify, last); + assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{last:?}: {got:?} vs right-aligned {right:?}"); + } +} + +/// The same holds when the direction is unset and the text itself resolves right to left. +#[test] +fn detected_rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start() { + let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}"; + let s = st(30.0); + let at = |align: Align| { + let p = ParagraphStyle { align, justify_last: Align::Left, rtl: None, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let (right, got) = (at(Align::Right), at(Align::Justify)); + assert!((got.0 - right.0).abs() < 1e-3 && (got.1 - right.1).abs() < 1e-3, "{got:?} vs right-aligned {right:?}"); +} + +/// Indents follow the resolved direction too: detected right-to-left text puts its start +/// indent on the right, as an explicit right-to-left paragraph does. +#[test] +fn detected_rtl_text_takes_its_indents_on_the_rtl_sides() { + let text = "\u{5d0}\u{5d1}\u{5d2} \u{5d3}\u{5d4}"; + let s = st(30.0); + let at = |rtl: Option| { + let p = ParagraphStyle { align: Align::Justify, justify_last: Align::Left, indent_start: 20.0, rtl, ..Default::default() }; + let l = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p]); + (l.lines[0].x, l.lines[0].x + l.lines[0].width) + }; + let (explicit, detected) = (at(Some(true)), at(None)); + assert!((detected.0 - explicit.0).abs() < 1e-3 && (detected.1 - explicit.1).abs() < 1e-3, "{detected:?} vs {explicit:?}"); +} + +/// Only the first line's own runs decide whether box text starts at the font ascent: a +/// vertical run further down leaves a horizontal first line on its `d`-height baseline. +#[test] +fn a_later_vertical_run_keeps_the_first_line_baseline() { + let text = "Top\nside"; + let horiz = st(40.0); + let vert = TextStyle { vertical: true, ..horiz.clone() }; + let p = ParagraphStyle { width: Some(400.0), ..Default::default() }; + let plain = layout_rich_uncached(text, &[(0..text.len(), horiz.clone())], std::slice::from_ref(&p)); + let mixed = layout_rich_uncached(text, &[(0..4, horiz), (4..text.len(), vert)], &[p]); + assert!((mixed.lines[0].baseline - plain.lines[0].baseline).abs() < 1e-3, "{} vs {}", mixed.lines[0].baseline, plain.lines[0].baseline); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 35e7ecc06..8cc8e3264 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -659,7 +659,9 @@ mod tests { let doc = TextDoc { text: "ab cd".into(), size: 20.0, box_size: Some([200.0, 100.0]), box_pos: [-100.0, -50.0], ..Default::default() }; let l = layout_doc(&doc); let (a, _) = l.caret(0); - assert!((a.x + 100.0).abs() < 1e-6 && (a.y + 50.0).abs() < 1.0, "{a:?}"); + // The first baseline sits the `d` height below the box top (After Effects), so the + // caret, which spans the font's full ascent, starts a little above the box. + assert!((a.x + 100.0).abs() < 1e-6 && a.y + 50.0 < 0.0 && a.y + 50.0 > -0.35 * 20.0, "{a:?}"); // Vertical: characters run down a column, carets are horizontal. let v = layout_doc(&TextDoc { text: "abc\nde".into(), size: 20.0, vertical: true, ..Default::default() }); let ys: Vec = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect(); diff --git a/crates/text/src/selectors.rs b/crates/text/src/selectors.rs index e3a422460..fcecfd814 100644 --- a/crates/text/src/selectors.rs +++ b/crates/text/src/selectors.rs @@ -71,6 +71,15 @@ impl Mode { pub fn combine(self, acc: f64, v: f64) -> f64 { let r = match self { Mode::Add => acc + v, + // Subtracting more than is selected leaves nothing, not a negative selection (After + // Effects 2026: a Subtract selector over unselected characters leaves their + // Tracking at 0, EXPORTER read-back t8). + // Intended for every animator property, not only Tracking: selectors combine into one + // selection per character for the whole animator (`render::text::animator_selection`), + // which then scales each of its properties, as in After Effects, where selectors + // belong to the animator. The read-back observed that selection through Tracking; + // Opacity, Position and the rest were not measured separately. + Mode::Subtract if acc >= 0.0 => (acc - v).max(0.0), Mode::Subtract => acc - v, Mode::Intersect => acc * v, Mode::Min => acc.min(v), @@ -352,6 +361,9 @@ mod tests { // A lone Subtract inverts. assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 1.0), 0.0); assert_eq!(Mode::Subtract.combine(Mode::Subtract.initial(), 0.0), 1.0); + // Subtracting from what is not selected leaves 0, not a negative selection (AE t8). + assert_eq!(Mode::Subtract.combine(0.0, 1.0), 0.0); + assert_eq!(Mode::Subtract.combine(0.25, 1.0), 0.0); assert_eq!(Mode::from_index(5), Mode::Difference); } diff --git a/crates/track/src/camtrack/bundle.rs b/crates/track/src/camtrack/bundle.rs index b8421f144..a4412dbce 100644 --- a/crates/track/src/camtrack/bundle.rs +++ b/crates/track/src/camtrack/bundle.rs @@ -11,7 +11,7 @@ use rayon::prelude::*; use super::geometry::Pose; -use super::linalg::{M3, V3, add, cholesky_solve, dot, inv3, mm, mtv, mv, rodrigues, scale, skew, sub}; +use super::linalg::{M3, V3, add, cholesky_solve, conjugate_gradient, dot, inv3, mm, mtv, mv, rodrigues, scale, skew, sub}; use super::{Distortion, SolveError}; /// A camera in the adjustment. @@ -287,6 +287,101 @@ fn cam_jac(l: &Layout, ci: usize, c: &BaCam, xc: V3, a: &[[f64; 3]; 2], p: [f64; } } +struct PtSys { + v: M3, + bp: V3, + w: Vec<(usize, V3)>, +} + +struct PreconditionerBlock { + start: usize, + dim: usize, + inverse: Vec, +} + +fn camera_preconditioner(u: &[f64], l: &Layout) -> Option> { + let mut ranges = Vec::new(); + let pose_dim = if l.rot_only { 3 } else { 6 }; + for (&pose, &focal) in l.start.iter().zip(&l.fidx) { + if pose != usize::MAX { + ranges.push((pose, pose_dim + usize::from(focal != usize::MAX))); + } else if focal != usize::MAX { + ranges.push((focal, 1)); + } + } + if let Some(start) = l.shared.or(l.dist) { + ranges.push((start, l.n - start)); + } + ranges + .into_iter() + .map(|(start, dim)| { + let mut matrix = Vec::with_capacity(dim * dim); + for i in 0..dim { + for j in 0..dim { + matrix.push(*u.get((start + i) * l.n + start + j)?); + } + } + let mut inverse = vec![0.0; dim * dim]; + for col in 0..dim { + let mut a = matrix.clone(); + let mut b = vec![0.0; dim]; + *b.get_mut(col)? = 1.0; + if !cholesky_solve(&mut a, dim, &mut b) { + return None; + } + for (row, value) in b.into_iter().enumerate() { + inverse[row * dim + col] = value; + } + } + Some(PreconditionerBlock { start, dim, inverse }) + }) + .collect() +} + +/// Apply the point-eliminated system without materialising W V^-1 W^T. +fn iterative_schur(u: &[f64], rhs: &[f64], systems: &[PtSys], vinv: &[Option], l: &Layout) -> Option> { + if l.n == 0 || l.n.checked_mul(l.n) != Some(u.len()) || rhs.len() != l.n || systems.len() != vinv.len() { + return None; + } + let blocks = camera_preconditioner(u, l)?; + // U contains camera blocks, global parameters and the focal prior. Store its nonzeros + // once; all subsequent products are linear in these and the point-camera connections. + let rows: Vec> = + u.chunks_exact(l.n).map(|row| row.iter().enumerate().filter(|(_, v)| **v != 0.0).map(|(i, v)| (i, *v)).collect()).collect(); + let mut reduced_rhs = rhs.to_vec(); + for (ps, vi) in systems.iter().zip(vinv) { + let Some(vi) = vi else { continue }; + let vb = mv(vi, ps.bp); + for (i, w) in &ps.w { + *reduced_rhs.get_mut(*i)? -= dot(*w, vb); + } + } + conjugate_gradient( + &reduced_rhs, + |x, y| { + for (i, row) in rows.iter().enumerate() { + y[i] = row.iter().map(|(j, v)| v * x[*j]).sum(); + } + for (ps, vi) in systems.iter().zip(vinv) { + let Some(vi) = vi else { continue }; + let q = ps.w.iter().fold([0.0; 3], |q, (i, w)| add(q, scale(*w, x[*i]))); + let q = mv(vi, q); + for (i, w) in &ps.w { + y[*i] -= dot(*w, q); + } + } + }, + |r, z| { + z.fill(0.0); + for block in &blocks { + for i in 0..block.dim { + z[block.start + i] = (0..block.dim).map(|j| block.inverse[i * block.dim + j] * r[block.start + j]).sum(); + } + } + }, + ) +} + /// Smooth per-camera log focal lengths in the normal equations. fn add_focal_prior(cams: &[BaCam], o: &BaOpts, l: &Layout, s_mat: &mut [f64], bc: &mut [f64]) { let n = l.n; @@ -310,10 +405,56 @@ fn add_focal_prior(cams: &[BaCam], o: &BaOpts, l: &Layout, s_mat: &mut [f64], bc } } +/// Eliminate the points into the camera system explicitly (`S -= W V^-1 W^T`, `rhs -= W V^-1 bp`). +fn reduce_points(systems: &[PtSys], vinv: &[Option], n: usize, s: &mut [f64], rhs: &mut [f64]) { + let npts = systems.len(); + let reduce = |range: std::ops::Range, s: &mut [f64], rhs: &mut [f64]| { + for j in range { + let (Some(vi), ps) = (&vinv[j], &systems[j]) else { continue }; + let vb = mv(vi, ps.bp); + let wv: Vec = ps.w.iter().map(|(_, w)| mv(vi, *w)).collect(); + for (a, (ia, wa)) in ps.w.iter().enumerate() { + rhs[*ia] -= dot(*wa, vb); + for (ib, wb) in &ps.w[a..] { + let d = dot(wv[a], *wb); + s[ia * n + ib] -= d; + if ia != ib { + s[ib * n + ia] -= d; + } + } + } + } + }; + let chunks = rayon::current_num_threads().clamp(1, 8); + if n <= 1200 && npts >= 64 && chunks > 1 { + let per = npts.div_ceil(chunks); + let parts: Vec<(Vec, Vec)> = (0..chunks) + .into_par_iter() + .map(|t| { + let (mut ds, mut dr) = (vec![0.0; n * n], vec![0.0; n]); + reduce(t * per..((t + 1) * per).min(npts), &mut ds, &mut dr); + (ds, dr) + }) + .collect(); + for (ds, dr) in parts { + s.par_iter_mut().zip(ds.par_iter()).for_each(|(x, y)| *x += y); + for (x, y) in rhs.iter_mut().zip(dr) { + *x += y; + } + } + } else { + reduce(0..npts, s, rhs); + } +} + /// Run Levenberg–Marquardt. `cams` and `pts` are updated in place. /// Invalid inputs or workspaces exceeding 256 MiB return an error before mutation. pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> Result { validate(cams, pts, obs, o)?; + adjust_impl(cams, pts, obs, o, true) +} + +fn adjust_impl(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts, allow_iterative: bool) -> Result { let l = layout(cams.len(), o); let matrix_len = matrix_len(l.n, pts.len(), obs.len())?; let npts = pts.len(); @@ -341,11 +482,6 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> iters += 1; // ---- Normal equations (Gauss–Newton with IRLS weights). // Per point: V (3×3), bp (3), W entries (cam-side index → 3). - struct PtSys { - v: [[f64; 3]; 3], - bp: V3, - w: Vec<(usize, V3)>, - } let build_point = |j: usize, s_acc: &mut Vec, bc: &mut Vec| -> PtSys { let mut ps = PtSys { v: [[0.0; 3]; 3], bp: [0.0; 3], w: vec![] }; let mut jc = CameraJacobian::new(); @@ -445,6 +581,10 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> let base_s = s_mat.clone(); let base_bc = bc.clone(); let mean_diag = ((0..n).map(|i| base_s[i * n + i]).sum::() / n.max(1) as f64).max(1e-12); + let schur_pairs = systems.iter().fold(0usize, |sum, ps| sum.saturating_add(ps.w.len().saturating_mul(ps.w.len().saturating_add(1)) / 2)); + // Reserve the iterative solve for expensive long-track reductions; small systems + // retain the direct path and its rounding. Failed solves use the direct path too. + let mut use_iterative = allow_iterative && !o.fix_points && n >= 120 && schur_pairs > 4_000_000; // ---- Try steps with increasing damping. let mut accepted = false; for _try in 0..12 { @@ -472,50 +612,26 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> }) .collect() }; - if !o.fix_points { - let reduce = |range: std::ops::Range, s: &mut [f64], rhs: &mut [f64]| { - for j in range { - let (Some(vi), ps) = (&vinv[j], &systems[j]) else { continue }; - let vb = mv(vi, ps.bp); - let wv: Vec = ps.w.iter().map(|(_, w)| mv(vi, *w)).collect(); - for (a, (ia, wa)) in ps.w.iter().enumerate() { - rhs[*ia] -= dot(*wa, vb); - for (ib, wb) in &ps.w[a..] { - let d = dot(wv[a], *wb); - s[ia * n + ib] -= d; - if ia != ib { - s[ib * n + ia] -= d; - } - } - } - } - }; - let chunks = rayon::current_num_threads().clamp(1, 8); - if n <= 1200 && npts >= 64 && chunks > 1 { - let per = npts.div_ceil(chunks); - let parts: Vec<(Vec, Vec)> = (0..chunks) - .into_par_iter() - .map(|t| { - let (mut ds, mut dr) = (vec![0.0; matrix_len], vec![0.0; n]); - reduce(t * per..((t + 1) * per).min(npts), &mut ds, &mut dr); - (ds, dr) - }) - .collect(); - for (ds, dr) in parts { - s.par_iter_mut().zip(ds.par_iter()).for_each(|(x, y)| *x += y); - for (x, y) in rhs.iter_mut().zip(dr) { - *x += y; - } - } - } else { - reduce(0..npts, &mut s, &mut rhs); + let iterative_step = if use_iterative { iterative_schur(&s, &rhs, &systems, &vinv, &l) } else { None }; + if dbg && use_iterative { + eprintln!("ba iterative n={n} pairs={schur_pairs} solved={}", iterative_step.is_some()); + } + if use_iterative && iterative_step.is_none() { + use_iterative = false; + } + let dc = if let Some(dc) = iterative_step { + dc + } else { + if !o.fix_points { + reduce_points(&systems, &vinv, n, &mut s, &mut rhs); } - } - let mut dc = rhs; - if n > 0 && !cholesky_solve(&mut s, n, &mut dc) { - lambda *= 10.0; - continue; - } + let mut dc = rhs; + if n > 0 && !cholesky_solve(&mut s, n, &mut dc) { + lambda *= 10.0; + continue; + } + dc + }; // Apply. let mut nc: Vec = cams.to_vec(); for (i, c) in nc.iter_mut().enumerate() { @@ -593,6 +709,112 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> mod tests { use super::*; use crate::camtrack::geometry::Rng; + use crate::camtrack::linalg::{I3, log_rot, mt, norm}; + + #[test] + fn long_track_adjustment_matches_direct_reprojections_and_outliers() { + rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap().install(|| { + for (focal, fix_centers, distortion) in + [(FocalMode::Fixed, false, false), (FocalMode::Shared, false, true), (FocalMode::PerCamera(30.0), false, true), (FocalMode::Shared, true, true)] + { + let mut rng = Rng(71); + let mut noise = || (rng.next_u64() % 100_000) as f64 / 100_000.0 - 0.5; + let truth: Vec = (0..320).map(|_| [noise() * 8.0, noise() * 5.0, 10.0 + noise() * 5.0]).collect(); + let true_cams: Vec<_> = (0..60) + .map(|i| BaCam { + pose: Pose { + r: rodrigues([0.001 * i as f64, -0.003 * i as f64, 0.0]), + c: if fix_centers { [0.0; 3] } else { [0.03 * i as f64, 0.01 * (i as f64).sin(), 0.0] }, + }, + f: if matches!(focal, FocalMode::PerCamera(_)) { 900.0 * (1.0 + 0.05 * (i as f64 * 0.07).sin()) } else { 900.0 }, + dist: if distortion { Distortion { k1: 0.015, k2: -0.005, radius: 1100.0 } } else { Distortion::NONE }, + }) + .collect(); + let mut obs = Vec::new(); + for (ci, c) in true_cams.iter().enumerate() { + for (pi, p) in truth.iter().enumerate() { + let mut uv = residual(c, *p, [0.0; 2]).unwrap(); + uv[0] += noise() * 0.05; + uv[1] += noise() * 0.05; + if (ci * truth.len() + pi).is_multiple_of(37) { + uv[0] += 25.0; + uv[1] -= 30.0; + } + obs.push(BaObs { cam: ci as u32, pt: pi as u32, uv }); + } + } + let mut cams = true_cams.clone(); + for c in cams.iter_mut().skip(2) { + c.pose.r = mm(&rodrigues([0.001, -0.001, 0.0005]), &c.pose.r); + if !fix_centers { + c.pose.c = add(c.pose.c, [0.002, -0.003, 0.001]); + } + } + let mut pts: Vec<_> = truth.iter().map(|p| add(*p, [0.01, -0.01, 0.005])).collect(); + let (mut direct_cams, mut direct_pts) = (cams.clone(), pts.clone()); + let opts = BaOpts { focal, fix_centers, distortion, fixed: (0..60).map(|i| i < 2).collect(), max_iter: 10, ..Default::default() }; + let report = adjust(&mut cams, &mut pts, &obs, &opts).unwrap(); + let direct = adjust_impl(&mut direct_cams, &mut direct_pts, &obs, &opts, false).unwrap(); + assert!((report.cost - direct.cost).abs() < 1e-5 * direct.cost.max(1.0)); + for ob in &obs { + let a = residual(&cams[ob.cam as usize], pts[ob.pt as usize], ob.uv).unwrap(); + let b = residual(&direct_cams[ob.cam as usize], direct_pts[ob.pt as usize], ob.uv).unwrap(); + assert!((a[0] - b[0]).hypot(a[1] - b[1]) < 0.001); + assert_eq!(a[0].hypot(a[1]) <= 4.0, b[0].hypot(b[1]) <= 4.0); + } + for (a, b) in cams.iter().zip(direct_cams) { + assert!(norm(sub(a.pose.c, b.pose.c)) < 0.001); + assert!(norm(log_rot(&mm(&a.pose.r, &mt(&b.pose.r)))) < 1e-4); + assert!((a.f - b.f).abs() < 1e-4 * b.f); + assert!((a.dist.k1 - b.dist.k1).abs() < 1e-4 && (a.dist.k2 - b.dist.k2).abs() < 1e-4); + } + } + }); + } + + #[test] + fn matrix_free_schur_matches_explicit_system_and_rejects_bad_preconditioner() { + let l = layout(20, &BaOpts::default()); + let n = l.n; + let mut u = vec![0.0; n * n]; + for i in 0..n { + u[i * n + i] = 10.0; + } + let systems: Vec<_> = (0..80) + .map(|j| PtSys { + v: I3, + bp: [0.01, -0.02, 0.03], + w: (0..n).map(|i| (i, [((j * 13 + i * 7) % 19) as f64 * 0.001 - 0.009, (i % 7) as f64 * 0.001, (j % 11) as f64 * 0.001])).collect(), + }) + .collect(); + let vinv = vec![Some(I3); systems.len()]; + let rhs: Vec<_> = (0..n).map(|i| (i % 7) as f64 - 3.0).collect(); + let x = iterative_schur(&u, &rhs, &systems, &vinv, &l).unwrap(); + let mut explicit = u.clone(); + let mut reduced_rhs = rhs.clone(); + for ps in &systems { + for (i, a) in &ps.w { + reduced_rhs[*i] -= dot(*a, ps.bp); + for (j, b) in &ps.w { + explicit[i * n + j] -= dot(*a, *b); + } + } + } + let mut direct = reduced_rhs.clone(); + let mut factor = explicit.clone(); + assert!(cholesky_solve(&mut factor, n, &mut direct)); + for (a, b) in x.iter().zip(direct) { + assert!((a - b).abs() < 1e-9); + } + let mut error2 = 0.0; + for (i, row) in explicit.chunks_exact(n).enumerate() { + let ax: f64 = row.iter().zip(&x).map(|(a, x)| a * x).sum(); + error2 += (ax - reduced_rhs[i]).powi(2); + } + assert!(error2 < 1e-18 * reduced_rhs.iter().map(|v| v * v).sum::()); + u[0] = -1.0; + assert!(iterative_schur(&u, &rhs, &systems, &vinv, &l).is_none()); + } fn test_camera() -> BaCam { BaCam { pose: Pose::IDENTITY, f: 900.0, dist: Distortion::NONE } diff --git a/crates/track/src/camtrack/linalg.rs b/crates/track/src/camtrack/linalg.rs index 692eef34e..71e88e109 100644 --- a/crates/track/src/camtrack/linalg.rs +++ b/crates/track/src/camtrack/linalg.rs @@ -398,6 +398,60 @@ pub fn cholesky_solve(a: &mut [f64], n: usize, b: &mut [f64]) -> bool { b.iter().all(|v| v.is_finite()) } +/// Preconditioned conjugate gradients with an independently recomputed residual check. +/// Failure to converge (or non-finite/non-positive curvature) requests the direct fallback. +pub(crate) fn conjugate_gradient(rhs: &[f64], apply: impl Fn(&[f64], &mut [f64]), precondition: impl Fn(&[f64], &mut [f64])) -> Option> { + let n = rhs.len(); + let norm2 = dot_n(rhs, rhs); + if !norm2.is_finite() { + return None; + } + if rhs.iter().all(|v| *v == 0.0) { + return Some(vec![0.0; n]); + } + let tolerance2 = norm2 * 1e-22; + if tolerance2 == 0.0 { + return None; + } + let mut x = vec![0.0; n]; + let mut r = rhs.to_vec(); + let mut z = vec![0.0; n]; + let mut ap = vec![0.0; n]; + precondition(&r, &mut z); + let mut p = z.clone(); + let mut rz = dot_n(&r, &z); + for _ in 0..n.saturating_mul(2).min(512) { + if !rz.is_finite() || rz <= 0.0 { + return None; + } + apply(&p, &mut ap); + let curvature = dot_n(&p, &ap); + if !curvature.is_finite() || curvature <= 0.0 { + return None; + } + let alpha = rz / curvature; + for i in 0..n { + x[i] += alpha * p[i]; + r[i] -= alpha * ap[i]; + } + if dot_n(&r, &r) <= tolerance2 { + apply(&x, &mut ap); + for i in 0..n { + ap[i] = rhs[i] - ap[i]; + } + return (x.iter().all(|v| v.is_finite()) && dot_n(&ap, &ap) <= 100.0 * tolerance2).then_some(x); + } + precondition(&r, &mut z); + let next_rz = dot_n(&r, &z); + let beta = next_rz / rz; + for i in 0..n { + p[i] = z[i] + beta * p[i]; + } + rz = next_rz; + } + None +} + /// A similarity transform `x ↦ s R x + t`. #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Similarity { @@ -586,6 +640,30 @@ mod tests { assert!(cholesky_solve(&mut [], 0, &mut [])); } + #[test] + fn conjugate_gradient_checks_residual_and_bad_curvature() { + let n = 64; + let rhs: Vec = (0..n).map(|i| (i % 11) as f64 - 5.0).collect(); + let apply = |x: &[f64], y: &mut [f64]| { + for i in 0..n { + y[i] = 4.0 * x[i] + if i > 0 { x[i - 1] } else { 0.0 } + if i + 1 < n { x[i + 1] } else { 0.0 }; + } + }; + let x = conjugate_gradient(&rhs, apply, |r, z| { + for (r, z) in r.iter().zip(z) { + *z = *r / 4.0; + } + }) + .unwrap(); + let mut product = vec![0.0; n]; + apply(&x, &mut product); + let residual: f64 = rhs.iter().zip(product).map(|(b, ax)| (b - ax).powi(2)).sum(); + assert!(residual < 1e-18 * dot_n(&rhs, &rhs)); + assert!(conjugate_gradient(&[1.0], |x, y| y[0] = -x[0], |r, z| z.copy_from_slice(r)).is_none()); + assert!(conjugate_gradient(&[f64::NAN], |_, _| {}, |_, _| {}).is_none()); + assert_eq!(conjugate_gradient(&[0.0], |_, _| {}, |_, _| {}).unwrap(), vec![0.0]); + } + #[test] fn svd_reconstructs() { let a = [3.0, 1.0, 2.0, -1.0, 4.0, 0.5, 2.0, 2.0, 7.0, 0.1, -3.0, 1.0]; diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index 3a98ac40f..52c600711 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -772,12 +772,13 @@ const JAPANESE_CATEGORIES: &[(&str, &str)] = &[ ("Color Correction", "カラー補正"), ("Distort", "ディストーション"), ("Expression Controls", "式制御"), + ("Generate", "描画"), ("Immersive Video", "イマーシブビデオ"), ("Keying", "キーイング"), ("Matte", "マット"), ("Noise & Grain", "ノイズ&グレイン"), ("Obsolete", "旧バージョン"), - ("Paint", "描画"), + ("Paint", "ペイント"), ("Perspective", "遠近"), ("Simulation", "シミュレーション"), ("Stylize", "スタイライズ"), @@ -1283,3 +1284,21 @@ mod tests { assert!(!matches("ec.blur.gaussian", "Gaussian Blur", "高斯")); } } + +#[cfg(test)] +mod japanese_category_tests { + use super::*; + use std::collections::HashMap; + + /// Two categories drawing the same Japanese name would make the Effect menu show one name twice and + /// leave the user unable to tell them apart. `描画` was both `Generate` and `Paint` before #2583. + #[test] + fn no_two_categories_share_a_japanese_name() { + let mut seen: HashMap<&str, &str> = HashMap::new(); + for (english, japanese) in JAPANESE_CATEGORIES { + if let Some(other) = seen.insert(japanese, english) { + panic!("`{english}` and `{other}` both draw as `{japanese}`"); + } + } + } +} diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index 84a218466..9f298ec19 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -171,7 +171,7 @@ fn type_tool_click_type_edit_and_commit() { /// #831: clicking another panel (a solid's row in the Timeline) ends text editing and gives the /// keyboard back, whether the edit began with the Type tool (which stays selected) or by command: /// typed characters change no text and Space plays. The Character panel keeps the edit, and a -/// text layer clicked into being and left empty goes away. +/// text layer clicked into being and left empty stays, as it does in After Effects. #[test] fn clicking_another_panel_stops_typing_into_text() { for type_tool in [false, true] { @@ -194,7 +194,7 @@ fn clicking_another_panel_stops_typing_into_text() { assert_eq!(layers(&h), 3, "the Type tool started a text layer"); let at_row = row(&h); click(&mut h, at_row, Modifiers::NONE); - assert_eq!(layers(&h), 2, "left empty, it is removed"); + assert_eq!(layers(&h), 3, "left empty, it stays, as in After Effects"); click(&mut h, at, Modifiers::NONE); } else { h.state_mut().session.execute("text.edit", json!({"layer": title})).unwrap(); diff --git a/docs/agents.md b/docs/agents.md index 581727385..172e5c4c6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -272,10 +272,10 @@ build real projects through these interfaces; they are worked examples of everyt then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or `{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`. -* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"]}` imports the file's whole - numbered run as one item (several picked frames: that range; a folder: each run in it; - `"sequence": false`: one still; dropped files, `"drag": true`, import as stills unless - `"sequence": true`, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files +* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"], "sequence": true}` imports the + file's whole numbered run as one item (several picked frames: that range; a folder: each run in + it, also without `sequence`). Without `"sequence": true` a picked or dropped numbered file imports + as one still, as in After Effects; `"alphabetical": true`: Force Alphabetical Order, the files play one after another) at `frameRate` or 30 fps (Settings ▸ Import). Missing frames show colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new @@ -540,7 +540,7 @@ indices): character attributes 3. `layer.setText {"range":[6,11], "size":40, "fill":"#ff5500"}` styles the selection (the Character panel does the same); with an empty range it sets the style the next typed text takes. 4. `edit.copy`, `edit.paste`, `edit.pasteTextMatchFormatting` and `edit.pasteTextFormattingOnly` - work on the selected text; `text.endEdit` commits (an empty Type-tool layer is removed). + work on the selected text; `text.endEdit` commits (an empty Type-tool layer is kept, as After Effects does). Fonts: `text.fonts` (MCP `list_fonts`) lists every family a text layer can use, bundled and installed, with its styles, origin (`bundled`, `system`, `user`), `variable` (it has variation axes) and own-language name diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index fc780016b..0398118c5 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -71,6 +71,45 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys, including a timing change. AE's output stays local; only the script and these scores are tracked. +## Justify All with the every-line composer + +Measured on 19 October 2026 with After Effects 2026 (26.2) on box text (EXPORTER read-back t6): +with Justify All, AE stretches the paragraph's last line too and breaks the lines so that it is not +a lone word (4 / 4 / 4 words where the other justifications break 6 / 6 / 1). EffectCraft's +every-line composer ignored the last line's slack and left "twelve" alone on a stretched line; it now +counts that slack for Justify All, and every word starts within 1 px of AE's. Regression test: +`justify_all_balances_the_lines_with_the_every_line_composer`. + +## Right-to-left point text with a justified paragraph + +Measured on 19 October 2026 with After Effects 2026 (26.2) (EXPORTER read-back t7): right-to-left +point text whose paragraph has a justified code (justify last left / centre / right, justify all) +sits flush right at its anchor, like right-aligned text. EffectCraft placed it by the last-line +side (left / centre / left). Now flush right; left-to-right point text is unchanged (not measured). +Regression test: `rtl_point_text_with_a_justified_paragraph_is_flush_at_its_start`. Still open: +AE wraps right-to-left box text slightly wider (its word gaps are 1–2 px narrower than in +left-to-right text with the same font), so a 300 px box fits a word more per line. + +## Text animator tracking + +Measured on 19 October 2026 with After Effects 2026 (26.2): a text animator's Tracking Amount is in +pixels, not 1/1000 em like the Character panel's tracking (Amount 30 adds 30 px between neighbouring +characters at 80 px and at 40 px, half before and half after each), and a Subtract range selector +over characters the first selector leaves out keeps them at 0 instead of tracking them negatively. +With both, the EXPORTER's t8 layer D (Tracking 30 on characters 2–4 by index, a Subtract selector +over the last 20 %) puts every glyph where AE does (it ended 88 px short). The "Tracking In" text +preset now animates 40 → 0 px (was 400 → 0 thousandths of an em). Lottie export is unchanged (not +checked against a player). Regression tests: `animator_tracking_is_in_pixels_as_in_after_effects`, +`modes_combine`. + +## Per-character 3D rotation order + +Measured on 19 October 2026 with After Effects 2026 (26.2) (case 931: "H" at 400 px, per-character +3D, animator X / Y / Z Rotation 40 / 30 / 20): a character turns as a 3D layer does, Rx · Ry · Rz +(Z first). EffectCraft composed Rz · Ry · Rx, so the glyph faced another way (23 of 5184 grid points +off, max 255); now 3 at the anti-aliased edge (mean 0.015). Regression test: +`per_character_rotation_turns_z_first_as_a_3d_layer`. + ## Rectangle path start point Measured on 19 October 2026 with After Effects 2026 (26.2) on 400 × 300 shape rectangles with Trim diff --git a/docs/preferences.md b/docs/preferences.md index ce96da0bf..2402b9a32 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -42,6 +42,7 @@ Commands (CLI, MCP, control channel): - `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch leaves a `launch-pending` marker in the settings folder until its window has been drawing for a few seconds (or the app quits normally before that); a launch that stops earlier (a graphics driver crashing as the window opens or as its surface is re-created) leaves the marker behind, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. - `project.useTemplate`: New Project Loads Template - `project.templatePath`: Template Project +- `project.compactProjectFiles`: Save Compact Project Files (default off). Off: File ▸ Save writes `.ecproj` files as indented JSON, one value per line, easy to read and diff in version control. On: compact JSON on one line, smaller and faster to save and open. Either opens the same; auto-saves, XML copies and template packages are not affected. - `autoSave.enabled`: Automatically Save Projects - `autoSave.intervalMinutes`: Save Every - `autoSave.maxVersions`: Maximum Project Versions diff --git a/docs/web.md b/docs/web.md index c18f7e869..1cdecab00 100644 --- a/docs/web.md +++ b/docs/web.md @@ -242,6 +242,25 @@ Window ▸ Media Browser browses a virtual tree (`src/browse.rs`): `mediaBrowser.list` reports the places and these actions; `mediaBrowser.action {action}` runs one. +### Startup project + +By default the page opens the last visit's project, and the demo project on a first visit. A +`startup` key in `effectcraft-config.json` (next to `index.html`) changes that: + +```json +{"startup":"empty"} +``` + +| Value | On load | +|---|---| +| `"last"` (or no key) | the last visit's project; the demo project on a first visit | +| `"empty"` | the last visit's project; a blank project on a first visit, never the demo | +| `"demo"` | the demo project, whatever the last visit left; the session is not saved, so the last visit's project stays as it was | + +The `?empty` and `?demo` URL flags still win. A missing, invalid or non-JSON file, or an unknown +value, is the default. The file is fetched once per page load; serve it with +`Cache-Control: no-cache` (the service worker never caches it). + ### Audio Audio preview goes through **Web Audio**: an AudioWorklet (`audio-worklet.js`; a diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx index 6f6eadae5..9f4ea2185 100644 --- a/extensions/scriptui-panels/Ease Presets.jsx +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -18,6 +18,8 @@ // segment on that side. So a selected pair gets the whole curve, and a single key works too. // Roving keys are skipped: they pass speed straight through (their sides stay linear), so an ease // set on them wouldn't play. +// The built-in Linear preset uses Linear interpolation, including on unused endpoint sides +// so a fully linear key has a diamond icon; the other presets use Bezier. // // User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: // {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", @@ -29,6 +31,7 @@ var MAX_NAME = 64; var MIN_INFLUENCE = 0.1; var MAX_SPEED = 100; + var LINEAR = KeyframeInterpolationType.LINEAR; var BEZIER = KeyframeInterpolationType.BEZIER; var HOLD = KeyframeInterpolationType.HOLD; @@ -178,16 +181,16 @@ // Ease one side of key `k` with `eases`, leaving its other side as it plays (auto-Bezier and // linear sides keep their shape; a continuous key stops being continuous, its sides now differ). - function easeSide(p, k, out, eases) { + function easeSide(p, k, out, eases, interpolation) { var inType = p.keyInInterpolationType(k), outType = p.keyOutInterpolationType(k); var inEase = p.keyInTemporalEase(k), outEase = p.keyOutTemporalEase(k); if (p.keyTemporalContinuous(k)) p.setTemporalContinuousAtKey(k, false); if (out) { p.setTemporalEaseAtKey(k, inEase, eases); - p.setInterpolationTypeAtKey(k, inType, BEZIER); + p.setInterpolationTypeAtKey(k, inType, interpolation); } else { p.setTemporalEaseAtKey(k, eases, outEase); - p.setInterpolationTypeAtKey(k, BEZIER, outType); + p.setInterpolationTypeAtKey(k, interpolation, outType); } } @@ -201,7 +204,7 @@ // Ease the sides `mode` names ("In", "Out" or "In-Out") of every selected keyframe with `c`, in // one undo step: a side with a neighbouring key, scaled to the segment on that side. Returns // {eased, roving}: the number of keyframes eased, and of selected roving keyframes skipped. - function applyCurve(c, mode) { + function applyCurve(c, mode, interpolation) { var keyed = selectedKeyed(), sides = [], keys = 0, roving = 0; var doIn = mode !== "Out", doOut = mode !== "In"; // Every side and its eases first: the segments are measured before any key changes. @@ -214,16 +217,16 @@ continue; } var s = doIn && k > 1 ? slopes(p, k - 1) : null; - if (s) sides.push({ prop: p, key: k, out: false, eases: eases(s, c.inSpeed, c.inInfluence) }); + if (s || (doIn && interpolation === LINEAR)) sides.push({ prop: p, key: k, out: false, eases: s ? eases(s, c.inSpeed, c.inInfluence) : p.keyInTemporalEase(k) }); s = doOut && k < p.numKeys ? slopes(p, k) : null; - if (s) sides.push({ prop: p, key: k, out: true, eases: eases(s, c.outSpeed, c.outInfluence) }); + if (s || (doOut && interpolation === LINEAR)) sides.push({ prop: p, key: k, out: true, eases: s ? eases(s, c.outSpeed, c.outInfluence) : p.keyOutTemporalEase(k) }); if (sides.length > before) keys++; } } if (!sides.length) return { eased: 0, roving: roving }; app.beginUndoGroup("Apply Ease Preset"); try { - for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases); + for (var e = 0; e < sides.length; e++) easeSide(sides[e].prop, sides[e].key, sides[e].out, sides[e].eases, interpolation); } finally { app.endUndoGroup(); } @@ -384,7 +387,10 @@ function apply(c, what) { var m = mode.selection ? mode.selection.text : "In-Out"; - var r = applyCurve(c, m), n = r.eased; + // Linear is an interpolation type too, so its keys get diamond icons. Edited speeds + // make a custom Bezier curve even while Linear remains selected in the list. + var interpolation = list.selection && list.selection.text === "Linear" && c.outSpeed === 1 && c.inSpeed === 1 ? LINEAR : BEZIER; + var r = applyCurve(c, m, interpolation), n = r.eased; var side = m === "In" ? "before it" : m === "Out" ? "after it" : "next to it"; var skipped = r.roving > 0 ? "skipped " + r.roving + " roving keyframe(s); turn off Roving to ease them" : ""; if (n > 0) status.text = "Eased " + n + " keyframe(s) (" + m + ") with " + what + (skipped ? "; " + skipped : ""); diff --git a/packaging/web/README.md b/packaging/web/README.md index 0a1f56fe8..672d9ad67 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -58,6 +58,19 @@ location /effectcraft/ { Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/. +## Startup project (optional) + +To choose what the page opens on load, add an `effectcraft-config.json` next to `index.html`: + +```json +{"startup":"empty"} +``` + +`"empty"` restores the last visit's project and otherwise starts blank (never the demo); `"demo"` +always opens the demo project without saving the session (the last visit's project stays); `"last"` (the default) restores the last visit's project and shows +the demo on a first visit. Serve the file with `Cache-Control: no-cache`. Details: +[docs/web.md](https://github.com/storytold/effectcraft/blob/main/docs/web.md#startup-project). + ## Embedding in a page (iframe) ```html diff --git a/packaging/web/_headers b/packaging/web/_headers index 013e1cf8a..e59592fb1 100644 --- a/packaging/web/_headers +++ b/packaging/web/_headers @@ -12,3 +12,6 @@ /index.html Cache-Control: no-cache + +/effectcraft-config.json + Cache-Control: no-cache diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index afc767de2..0f8072840 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -13,3 +13,4 @@ serde_json = { workspace = true } chrono = { workspace = true } tempfile = "3" +pkg-config = "0.3" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index da8026d78..ad03cada7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -407,6 +407,62 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> { Ok(()) } +/// The guidance printed when the ALSA pkg-config package is missing. Split out so the tests can +/// check the package names without needing a machine that is actually missing them. +#[cfg(target_os = "linux")] +fn audio_backend_missing_hint(pkg_config_stderr: Option<&str>) -> String { + let mut hint = String::from( + "Linux audio needs the ALSA development package, and pkg-config cannot find it.\n\ + The desktop app plays preview sound through cpal, whose alsa-sys dependency asks\n\ + pkg-config for `alsa`; without the package the build dies minutes later with an\n\ + unrelated-looking linker error.\n\ + \n\ + Fedora: sudo dnf install alsa-lib-devel\n\ + Debian/Ubuntu: sudo apt install libasound2-dev\n\ + Arch: sudo pacman -S alsa-lib\n\ + \n\ + Install one of those and run `cargo xtask ci` again. If you cannot install it, point\n\ + PKG_CONFIG_PATH at a directory holding an `alsa.pc` whose Libs line names your system's\n\ + libasound (usually `libasound.so.2`); every CI run will need that variable set.", + ); + if let Some(stderr) = pkg_config_stderr { + hint.push_str("\n\npkg-config said: "); + hint.push_str(stderr); + } + hint +} + +/// `cargo xtask ci` builds the desktop app on the host, so on Linux it needs a usable ALSA +/// pkg-config package before the build dies minutes later at link time. The probe goes through +/// the same `pkg-config` crate alsa-sys's build script uses, so the executable and search-path +/// lookup match by construction: `PKG_CONFIG` and its target-scoped variants, then `pkg-config` +/// on `PATH` with a `pkgconf` fallback, plus the scoped `PKG_CONFIG_PATH`/`LIBDIR`/`SYSROOT_DIR`. +/// A probe that ran and said no is "ALSA missing"; one that could not run is reported separately, +/// so a machine with only `pkgconf` does not fail a build that would succeed. +#[cfg(target_os = "linux")] +fn audio_backend() -> Result<(), String> { + // cargo_metadata off: xtask is not a build script, so the crate's rerun-if-env lines would + // only clutter the ci log. + match pkg_config::Config::new().cargo_metadata(false).probe("alsa") { + Ok(_) => Ok(()), + Err(pkg_config::Error::ProbeFailure { output, .. }) | Err(pkg_config::Error::Failure { output, .. }) => { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + Err(audio_backend_missing_hint((!stderr.is_empty()).then_some(stderr))) + } + Err(e) => Err(format!( + "The pkg-config probe for `alsa` could not be run: {e}. Install pkg-config (or pkgconf), \ + or point PKG_CONFIG at a working one — it is the same lookup alsa-sys's pkg-config \ + dependency performs." + )), + } +} + +#[cfg(not(target_os = "linux"))] +fn audio_backend() -> Result<(), String> { + Ok(()) +} + /// Lib, bin and integration tests (examples aren't linked; clippy checked them), then doctests, /// which nextest doesn't run. /// @@ -438,7 +494,8 @@ type Stage = fn() -> Result<(), String>; /// Every stage runs even when an earlier one fails, so one run reports all the problems. fn ci() -> Result<(), String> { - let stages: [(&str, Stage); 7] = [ + let stages: [(&str, Stage); 8] = [ + ("audio backend", audio_backend), ("fmt", || run(Command::new(env!("CARGO")).args(["fmt", "--check"]))), ("clippy", || run(Command::new(env!("CARGO")).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), ("tests", tests), @@ -459,6 +516,7 @@ fn main() -> ExitCode { "assets" => assets(), "pdf-deps" => pdf_deps::run(&root()), "ci" => ci(), + "audio-backend" => audio_backend(), "web" => web(&std::env::args().skip(2).collect::>()), "bench" => bench::run(&root(), &std::env::args().skip(2).collect::>()), "ico" => { @@ -469,7 +527,7 @@ fn main() -> ExitCode { let rest: Vec = std::env::args().skip(2).collect(); version::run(&root(), &rest.iter().map(String::as_str).collect::>()) } - _ => Err("usage: cargo xtask ".into()), + _ => Err("usage: cargo xtask ".into()), }; match r { Ok(()) => ExitCode::SUCCESS, @@ -484,3 +542,22 @@ fn main() -> ExitCode { pub fn root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf() } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::audio_backend_missing_hint; + + #[test] + fn alsa_missing_names_the_package_for_each_distro() { + let e = audio_backend_missing_hint(None); + for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config", "PKG_CONFIG_PATH"] { + assert!(e.contains(want), "hint lost `{want}`:\n{e}"); + } + } + + #[test] + fn alsa_missing_appends_pkg_config_s_own_detail() { + let e = audio_backend_missing_hint(Some("Package 'alsa' not found")); + assert!(e.contains("pkg-config said: Package 'alsa' not found"), "detail lost:\n{e}"); + } +} diff --git a/xtask/tests/it/audio_probe.rs b/xtask/tests/it/audio_probe.rs new file mode 100644 index 000000000..d984c7637 --- /dev/null +++ b/xtask/tests/it/audio_probe.rs @@ -0,0 +1,126 @@ +//! The Linux audio-backend preflight against fake pkg-config tools. +//! +//! The probe resolves its executable through the pkg-config crate, which reads the environment, +//! so the fake-tool scenarios run the real `xtask audio-backend` binary in a child process with a +//! controlled environment: `Command::env` changes only the child's environment, so the tests need +//! no global env mutation and stay parallel-safe. + +use std::ffi::OsStr; +use std::path::PathBuf; +use std::process::{Command, Output}; + +/// A scratch directory holding the fake tools for one scenario: `dir` is the child's `PATH`, +/// `off_path` holds executables reachable only by an explicit `*_PKG_CONFIG` override. +struct FakeTools { + dir: PathBuf, + off_path: PathBuf, +} + +impl FakeTools { + fn new(tag: &str) -> Self { + let base = std::env::temp_dir().join(format!("effectcraft-xtask-audio-{tag}-{}", std::process::id())); + let dir = base.join("bin"); + let off_path = base.join("off-path"); + std::fs::create_dir_all(&dir).expect("create PATH dir"); + std::fs::create_dir_all(&off_path).expect("create off-PATH dir"); + Self { dir, off_path } + } + + /// Write a plain (not executable) file. + fn file(&self, on_path: bool, name: &str, body: &str) -> PathBuf { + let path = if on_path { self.dir.clone() } else { self.off_path.clone() }.join(name); + std::fs::write(&path, body).expect("write fake tool"); + path + } + + /// Write an executable shell script. + fn script(&self, on_path: bool, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = self.file(on_path, name, body); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod fake tool"); + path + } + + /// A fake `pkgconf` that answers as alsa 1.2.11 linking -lasound. + fn working_pkgconf(&self, on_path: bool) -> PathBuf { + self.script(on_path, "pkgconf", "#!/bin/sh\ncase \" $* \" in\n *\" --modversion \"*) echo 1.2.11 ;;\n *) echo -lasound ;;\nesac\n") + } +} + +impl Drop for FakeTools { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + let _ = std::fs::remove_dir_all(&self.off_path); + } +} + +/// Run the built `xtask audio-backend` with `dir` as the only `PATH`, the given variables set, +/// and every variable the probe reads otherwise cleared, so a developer's own environment +/// cannot leak into the scenario. +fn run_audio_backend(tools: &FakeTools, set: &[(&str, &OsStr)]) -> Output { + Command::new(env!("CARGO_BIN_EXE_xtask")) + .arg("audio-backend") + .env("PATH", &tools.dir) + .env_remove("PKG_CONFIG") + .env_remove("PKG_CONFIG_PATH") + .env_remove("PKG_CONFIG_LIBDIR") + .env_remove("PKG_CONFIG_SYSROOT_DIR") + .env_remove("PKG_CONFIG_ALLOW_CROSS") + .env_remove("ALSA_NO_PKG_CONFIG") + .env_remove("HOST_PKG_CONFIG") + .env_remove("TARGET_PKG_CONFIG") + .env_remove("HOST") + .env_remove("TARGET") + .envs(set.iter().copied()) + .output() + .expect("run xtask audio-backend") +} + +#[test] +fn a_host_with_only_pkgconf_still_finds_alsa() { + // The reviewed case: PKG_CONFIG unset, no pkg-config on PATH, only pkgconf. The pkg-config + // crate alsa-sys uses falls back to pkgconf, so the stage must stay green. + let tools = FakeTools::new("pkgconf-only"); + tools.working_pkgconf(true); + let out = run_audio_backend(&tools, &[]); + assert!(out.status.success(), "pkgconf-only host must pass:\n{}", String::from_utf8_lossy(&out.stderr)); +} + +#[test] +fn a_probe_that_runs_and_says_no_names_the_package() { + let tools = FakeTools::new("says-no"); + tools.script(true, "pkg-config", "#!/bin/sh\necho 'Package alsa was not found' >&2\nexit 1\n"); + let out = run_audio_backend(&tools, &[]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config"] { + assert!(stderr.contains(want), "hint lost `{want}`:\n{stderr}"); + } + assert!(stderr.contains("not found"), "pkg-config's own detail should be appended:\n{stderr}"); +} + +#[test] +fn a_probe_that_cannot_run_is_not_alsa_missing() { + // PKG_CONFIG names a file that is not an executable; with PKG_CONFIG set the crate does not + // fall back to pkgconf, so the lookup fails to launch — reported separately from "missing". + let tools = FakeTools::new("cannot-run"); + let broken = tools.file(true, "not-an-executable", "this is not a program\n"); + let out = run_audio_backend(&tools, &[("PKG_CONFIG", broken.as_os_str())]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("could not"), "expected the launch-failure error:\n{stderr}"); + assert!(!stderr.contains("alsa-lib-devel"), "must not read as ALSA missing:\n{stderr}"); +} + +#[test] +fn a_host_scoped_override_selects_that_executable() { + // With TARGET and HOST set (build-script style), HOST_PKG_CONFIG wins over the PATH lookup, + // and here it is the only working tool — so success proves the override was used. + let tools = FakeTools::new("scoped"); + let good = tools.working_pkgconf(false); + let out = run_audio_backend( + &tools, + &[("HOST", OsStr::new("x86_64-unknown-linux-gnu")), ("TARGET", OsStr::new("x86_64-unknown-linux-gnu")), ("HOST_PKG_CONFIG", good.as_os_str())], + ); + assert!(out.status.success(), "HOST_PKG_CONFIG override must be honored:\n{}", String::from_utf8_lossy(&out.stderr)); +} diff --git a/xtask/tests/it/main.rs b/xtask/tests/it/main.rs new file mode 100644 index 000000000..f2c41c738 --- /dev/null +++ b/xtask/tests/it/main.rs @@ -0,0 +1,4 @@ +//! xtask's integration tests: one binary, modules in `tests/it/`. + +#[cfg(target_os = "linux")] +mod audio_probe;