mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Merge remote-tracking branch 'origin/main' into pr1736-avi-backend
This commit is contained in:
@@ -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
@@ -7246,6 +7246,7 @@ name = "xtask"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"pkg-config",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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})")))?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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}`");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 : "");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,3 +12,6 @@
|
||||
|
||||
/index.html
|
||||
Cache-Control: no-cache
|
||||
|
||||
/effectcraft-config.json
|
||||
Cache-Control: no-cache
|
||||
|
||||
@@ -13,3 +13,4 @@ serde_json = { workspace = true }
|
||||
|
||||
chrono = { workspace = true }
|
||||
tempfile = "3"
|
||||
pkg-config = "0.3"
|
||||
|
||||
+79
-2
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! xtask's integration tests: one binary, modules in `tests/it/`.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod audio_probe;
|
||||
Reference in New Issue
Block a user