Merge remote-tracking branch 'origin/main' into pr1736-avi-backend

This commit is contained in:
tylerjenningsw
2026-10-29 18:07:39 -04:00
46 changed files with 1273 additions and 139 deletions
+16
View File
@@ -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
Generated
+1
View File
@@ -7246,6 +7246,7 @@ name = "xtask"
version = "0.18.0"
dependencies = [
"chrono",
"pkg-config",
"serde_json",
"tempfile",
]
+8
View File
@@ -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",
+24 -8
View File
@@ -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<String>) {
/// 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?;
+73
View File
@@ -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<Option<Option<Vec<u8>>>> = 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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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
}
+4 -1
View File
@@ -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;
+55
View File
@@ -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::<Value>(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
}
}
+1
View File
@@ -0,0 +1 @@
mod startup_tests;
@@ -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());
}
+3 -1
View File
@@ -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);
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -152,7 +152,7 @@ fn clear_recent_presets(s: &mut Session, _: &Value) -> Result<Value> {
fn save_copy_xml(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopyAsXml", "missing `path` (.ecprojx)"))?;
let xml = crate::xml_project::to_xml(&s.project.to_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()}))
+3 -3
View File
@@ -74,9 +74,9 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(s.state.selected_layers.len()))
}
fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
// 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();
+2 -1
View File
@@ -87,7 +87,8 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
/// 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<String> {
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) }
}
+1 -1
View File
@@ -161,7 +161,7 @@ pub(super) fn text_range_p(p: &Value) -> Option<std::ops::Range<usize>> {
/// 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<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
+9 -12
View File
@@ -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<CharStyle>,
/// 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<Value> {
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<Value> {
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)
@@ -122,6 +122,11 @@ pub fn pages() -> Vec<Page> {
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),
@@ -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 {
+17
View File
@@ -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]
+7 -9
View File
@@ -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");
}
+6 -4
View File
@@ -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<String, ProjectError> {
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<String, ProjectError> {
/// [`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<String, ProjectError> {
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})")))?;
+2 -2
View File
@@ -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.
+10 -3
View File
@@ -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<TextGeom> {
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<TextGeom> {
};
// 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));
+33 -2
View File
@@ -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);
+34
View File
@@ -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));
}
+73 -20
View File
@@ -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<f32> {
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<f32> {
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<usize>,
glyphs: Vec<Glyph>,
@@ -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<usize>, 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<f32>, first: bool) -> Option<f32> {
let (l, r) = margins_for(para, first);
fn avail_for(para: &ParagraphStyle, rtl: bool, width: Option<f32>, first: bool) -> Option<f32> {
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<f32>, last: bool) -> Align {
if (last || width.is_none()) && para.align == Align::Justify {
fn align_for(para: &ParagraphStyle, rtl: bool, width: Option<f32>, 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<usize>, 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<usize>, 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<Range<usize>> {
let last_counts = self.para.align == Align::Justify && self.para.justify_last == Align::Justify;
let nodes: Vec<usize> = 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<usize>, 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<usize>, glyphs: &[Glyph]) -> (f32, f32, f32) {
let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32);
fn line_metrics(&self, r: &Range<usize>, 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<usize> = 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<usize>, 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;
+137
View File
@@ -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<f32>, 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<usize> { 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<bool>| {
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);
}
+3 -1
View File
@@ -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<f64> = v.glyphs.iter().filter(|g| g.line_index == 0).map(|g| g.origin.y).collect();
+12
View File
@@ -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);
}
+271 -49
View File
@@ -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<f64>,
}
fn camera_preconditioner(u: &[f64], l: &Layout) -> Option<Vec<PreconditionerBlock>> {
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<M3>], l: &Layout) -> Option<Vec<f64>> {
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<Vec<(usize, f64)>> =
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<M3>], n: usize, s: &mut [f64], rhs: &mut [f64]) {
let npts = systems.len();
let reduce = |range: std::ops::Range<usize>, 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<V3> = 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<f64>, Vec<f64>)> = (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<BaReport, SolveError> {
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<BaReport, SolveError> {
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<f64>, bc: &mut Vec<f64>| -> 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::<f64>() / 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<usize>, 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<V3> = 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<f64>, Vec<f64>)> = (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<BaCam> = 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<V3> = (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::<f64>());
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 }
+78
View File
@@ -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<Vec<f64>> {
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<f64> = (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];
+20 -1
View File
@@ -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}`");
}
}
}
}
+2 -2
View File
@@ -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();
+5 -5
View File
@@ -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
+39
View File
@@ -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
+1
View File
@@ -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
+19
View File
@@ -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
+14 -8
View File
@@ -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 : "");
+13
View File
@@ -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
+3
View File
@@ -12,3 +12,6 @@
/index.html
Cache-Control: no-cache
/effectcraft-config.json
Cache-Control: no-cache
+1
View File
@@ -13,3 +13,4 @@ serde_json = { workspace = true }
chrono = { workspace = true }
tempfile = "3"
pkg-config = "0.3"
+79 -2
View File
@@ -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::<Vec<_>>()),
"bench" => bench::run(&root(), &std::env::args().skip(2).collect::<Vec<_>>()),
"ico" => {
@@ -469,7 +527,7 @@ fn main() -> ExitCode {
let rest: Vec<String> = std::env::args().skip(2).collect();
version::run(&root(), &rest.iter().map(String::as_str).collect::<Vec<_>>())
}
_ => Err("usage: cargo xtask <layers|assets|pdf-deps|wasm|web [--dev] [--serve PORT]|ico OUT.ico IN.png…|version [set X.Y.Z]|ci|bench [--bin CLI] [--quick] [--project F --comp C] [--compare OLD.jsonl] (--help)>".into()),
_ => Err("usage: cargo xtask <layers|assets|pdf-deps|wasm|web [--dev] [--serve PORT]|ico OUT.ico IN.png…|version [set X.Y.Z]|ci|audio-backend|bench [--bin CLI] [--quick] [--project F --comp C] [--compare OLD.jsonl] (--help)>".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}");
}
}
+126
View File
@@ -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));
}
+4
View File
@@ -0,0 +1,4 @@
//! xtask's integration tests: one binary, modules in `tests/it/`.
#[cfg(target_os = "linux")]
mod audio_probe;