G2: merge main and preserve blank panel menus and selection

This commit is contained in:
Jonas Neubauer committed 2026-10-08 02:06:56 +02:00
commit 8af8a11500
49 files changed
+1365 -117

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
on:
+1 -1
View File
@@ -1,6 +1,6 @@
# EffectCraft — instructions for agents
EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual effects compositor targeting Adobe After Effects parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../printcraft` (Acrobat), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom) and `../designcraft` (InDesign), with the same conventions.
EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual effects compositor targeting Adobe After Effects parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom) and `../designcraft` (InDesign), with the same conventions.
## Start every session here
`plan/` is maintainer-local (gitignored). Public equivalents: [`ROADMAP.md`](ROADMAP.md), [`docs/`](docs/).
+1 -1
View File
@@ -8,7 +8,7 @@ work apply to everyone, people and AI agents alike, and they are not negotiable.
You need [Rust](https://rustup.rs/) 1.95 or newer.
```sh
cargo run -p effectcraft # the desktop app, with the demo project
cargo run -p effectcraft -- --demo # the desktop app, with the demo project
cargo test --workspace
cargo xtask ci # what every commit must pass
```
+2 -2
View File
@@ -193,7 +193,7 @@ To build it yourself you need [Rust](https://rustup.rs/) 1.95 or newer.
```sh
git clone https://github.com/storytold/effectcraft
cd effectcraft
cargo run --release -p effectcraft # the app, with the demo project open
cargo run --release -p effectcraft # the app (add `-- --demo` to open the demo project)
cargo run --release -p effectcraft-cli -- render --out intro.mp4 # render the demo headless
cargo xtask web --serve 8765 # the browser build on http://127.0.0.1:8765/ (docs/web.md)
```
@@ -247,7 +247,7 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | **Motion graphics and visual effects · you are here** | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
+10 -6
View File
@@ -278,8 +278,8 @@ export function audioStop() {
// ------------------------------------------------------------------ workers
// Idle workers are reused; each keeps the files it was sent (path → size) so a file goes to a
// worker once.
// Idle workers are reused; each keeps the files it was sent (path → version) so a file goes to a
// worker once, and again when it was replaced (even by one of the same size, #218).
const idle = [];
const running = new Map(); // job id → {worker, done}
// Job workers open a WebGPU device of their own (unless `?nogpuworkers`): their jobs' frames
@@ -310,7 +310,8 @@ function spawn(base) {
return w;
}
/// Run a job in a worker. `files`: [[path, Uint8Array]] the job reads; `onMessage(type, json,
/// Run a job in a worker. `files`: [[path, Uint8Array, version]] the job reads (`version`
/// changes with every write of the file); `onMessage(type, json,
/// path, bytes)` receives "reply" (json), "file" (path, bytes: a rendered file to download),
/// "store" (path, bytes: a file to keep in browser storage) and "error" (json = message).
export function workerRun(id, json, files, base, onMessage) {
@@ -339,14 +340,17 @@ export function workerRun(id, json, files, base, onMessage) {
onMessage("error", String(e.message || "worker error"), null, null);
w.worker.terminate();
};
for (const [path, bytes] of files) {
if (w.files.get(path) === bytes.length) continue;
w.files.set(path, bytes.length);
for (const [path, bytes, version] of files) {
const tag = version ?? bytes.length;
if (w.files.get(path) === tag) continue;
w.files.set(path, tag);
w.worker.postMessage({ type: "file", path, bytes });
}
w.worker.postMessage({ type: "job", json });
}, (e) => {
// The worker didn't start (#216): the job fails and the worker goes.
running.delete(id);
w.worker.terminate();
onMessage("error", String(e.message || e), null, null);
});
}
+2 -1
View File
@@ -148,7 +148,8 @@ pub fn remove_stored(path: String) -> bool {
crate::files::STORE.remove_file(&path)
}
/// Resolves once every change is written to browser storage.
/// Resolves once every change is written to browser storage; rejects when a write failed (it is
/// tried again).
#[wasm_bindgen]
pub fn flush() -> js_sys::Promise {
crate::persist::flushed()
+6
View File
@@ -35,6 +35,12 @@ pub fn get(path: &str) -> Option<Arc<[u8]>> {
STORE.file(path)
}
/// A file's bytes and version (new with every write: workers resend a file whose version they
/// haven't seen).
pub fn get_versioned(path: &str) -> Option<(Arc<[u8]>, u64)> {
STORE.file_versioned(path)
}
/// `(path, size)` of every file in the table.
pub fn list() -> Vec<(String, usize)> {
STORE.files().into_iter().map(|(p, n, _, _)| (p, n)).collect()
+9 -9
View File
@@ -19,7 +19,7 @@
//! frame times. Without WebGPU, frames render on the worker's CPU.
use std::cell::RefCell;
use std::collections::HashSet;
use std::collections::HashMap;
use std::sync::Arc;
use effectcraft_engine::offload::footage_files;
@@ -45,7 +45,8 @@ extern "C" {
struct Fw {
index: u32,
mirror: Mirror,
files: HashSet<String>,
/// The footage files it was sent, with their versions (a replaced file goes again).
files: HashMap<String, u64>,
busy: Option<(u64, RemoteDone)>,
alive: bool,
/// Its GPU adapter (after it reported), or why it has none.
@@ -99,7 +100,7 @@ impl WebFrames {
let mut s = s.borrow_mut();
for _ in 0..n {
let index = frame_worker_start(&crate::page_url(""), on.as_ref().unchecked_ref(), gpu);
s.workers.push(Fw { index, mirror: Mirror::default(), files: HashSet::new(), busy: None, alive: true, gpu: None });
s.workers.push(Fw { index, mirror: Mirror::default(), files: HashMap::new(), busy: None, alive: true, gpu: None });
}
});
HANDLER.with(|h| *h.borrow_mut() = Some(on));
@@ -116,14 +117,13 @@ impl WebFrames {
// Footage the worker hasn't got yet.
let w = &mut s.workers[k];
for p in footage_files(&job.project) {
if w.files.contains(&p) {
let Some((d, version)) = crate::files::get_versioned(&p) else { continue };
if w.files.get(&p) == Some(&version) {
continue;
}
if let Some(d) = crate::files::get(&p) {
let bytes = js_sys::Uint8Array::from(&d[..]);
frame_worker_post(w.index, &obj(&[("type", "file".into()), ("path", p.as_str().into()), ("bytes", bytes.into())]), &js_sys::Array::new());
w.files.insert(p);
}
let bytes = js_sys::Uint8Array::from(&d[..]);
frame_worker_post(w.index, &obj(&[("type", "file".into()), ("path", p.as_str().into()), ("bytes", bytes.into())]), &js_sys::Array::new());
w.files.insert(p, version);
}
let sync = w.mirror.sync(job.revision, &job.project);
let index = w.index;
+64 -9
View File
@@ -3,7 +3,8 @@
//!
//! - [`load`] (before the app starts) opens the storage and reads every entry into the mirror.
//! - Changes flush in the background ([`schedule_flush`], wired as the store's change hook):
//! one write per changed key, in order, with nothing written twice concurrently.
//! one write per changed key, in order, with nothing written twice concurrently. A write that
//! fails (quota exceeded…) is kept and tried again, after 2 s, then longer, up to a minute.
//! - The session snapshot ([`snapshot`]) records the open project and editor state every second
//! or so while it changes, so a reload (or a crash) comes back to where the user was
//! ([`restore`]).
@@ -37,11 +38,18 @@ thread_local! {
static FLUSHING: Cell<bool> = const { Cell::new(false) };
static BACKEND: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
static LAST_ERROR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
static FLUSH_WAITERS: std::cell::RefCell<Vec<js_sys::Function>> = const { std::cell::RefCell::new(Vec::new()) };
/// [`flushed`] promises waiting for the running flush: (resolve, reject).
static FLUSH_WAITERS: std::cell::RefCell<Vec<(js_sys::Function, js_sys::Function)>> = const { std::cell::RefCell::new(Vec::new()) };
/// Delay before retrying failed writes (ms; doubles while they keep failing).
static RETRY_MS: Cell<i32> = const { Cell::new(RETRY_FIRST_MS) };
static RETRY_PENDING: Cell<bool> = const { Cell::new(false) };
static SNAP: Cell<(u64, f64)> = const { Cell::new((u64::MAX, 0.0)) };
static LAST_META: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
}
const RETRY_FIRST_MS: i32 = 2_000;
const RETRY_MAX_MS: i32 = 60_000;
/// The storage in use: `opfs`, `indexeddb` or `memory`.
pub fn backend() -> String {
BACKEND.with(|b| b.borrow().clone())
@@ -86,8 +94,11 @@ pub fn schedule_flush() {
return; // the running flush picks the new changes up
}
wasm_bindgen_futures::spawn_local(async {
// Keys whose write failed in this flush, and the last failure.
let mut failed: Vec<String> = vec![];
let mut error = None;
loop {
let pending = STORE.lock().take_pending();
let pending: Vec<_> = STORE.lock().take_pending().into_iter().filter(|p| !failed.contains(&p.key)).collect();
if pending.is_empty() {
break;
}
@@ -99,24 +110,68 @@ pub fn schedule_flush() {
if let Err(e) = r {
let msg = format!("saving {} to browser storage failed: {}", p.key, js_err(e));
log::warn!("{msg}");
LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg));
LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg.clone()));
error = Some(msg);
failed.push(p.key);
}
}
}
// Failed writes stay pending (#215): flushed later, and the waiters hear of the failure
// instead of being told the changes are stored.
{
let mut m = STORE.lock();
for k in &failed {
m.retry(k);
}
}
FLUSHING.with(|f| f.set(false));
for w in FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut())) {
let _ = w.call0(&JsValue::NULL);
let waiters = FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut()));
match &error {
None => {
RETRY_MS.with(|r| r.set(RETRY_FIRST_MS));
for (resolve, _) in waiters {
let _ = resolve.call0(&JsValue::NULL);
}
}
Some(msg) => {
let e: JsValue = js_sys::Error::new(msg).into();
for (_, reject) in waiters {
let _ = reject.call1(&JsValue::NULL, &e);
}
retry_later();
}
}
});
}
/// A promise that resolves once every change so far is in the browser storage.
/// Flush again after the retry delay (once; the delay doubles up to a minute while writes keep
/// failing).
fn retry_later() {
if RETRY_PENDING.with(|r| r.replace(true)) {
return;
}
let ms = RETRY_MS.with(|r| {
let ms = r.get();
r.set(ms.saturating_mul(2).min(RETRY_MAX_MS));
ms
});
let again = Closure::once_into_js(|| {
RETRY_PENDING.with(|r| r.set(false));
schedule_flush();
});
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(again.unchecked_ref(), ms);
}
}
/// A promise that resolves once every change so far is in the browser storage, and rejects
/// when a write failed (the change stays pending and is tried again).
pub fn flushed() -> js_sys::Promise {
js_sys::Promise::new(&mut |resolve, _| {
js_sys::Promise::new(&mut |resolve, reject| {
if !FLUSHING.with(|f| f.get()) && !STORE.lock().has_pending() {
let _ = resolve.call0(&JsValue::NULL);
} else {
FLUSH_WAITERS.with(|w| w.borrow_mut().push(resolve));
FLUSH_WAITERS.with(|w| w.borrow_mut().push((resolve, reject)));
schedule_flush();
}
})
+56 -2
View File
@@ -42,6 +42,9 @@ pub struct Entry {
pub modified: f64,
/// Written to the browser's storage (render outputs are not).
pub persist: bool,
/// Which write this is: new with every write of the key, so a file replaced by another of
/// the same size still reads as changed (workers are sent it again, #218).
pub version: u64,
}
/// A queued write for the browser storage: `data` = `None` deletes the key.
@@ -58,17 +61,21 @@ pub struct Mirror {
entries: BTreeMap<String, Entry>,
/// Keys changed since the last [`Mirror::take_pending`], in first-change order.
dirty: Vec<String>,
/// The last [`Entry::version`] given.
version: u64,
}
impl Mirror {
/// An entry read from the browser's storage at startup (nothing to flush).
pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) {
self.entries.insert(key.to_string(), Entry { data, modified, persist: true });
let version = self.next_version();
self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version });
}
pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) {
let was_persisted = self.entries.get(key).is_some_and(|e| e.persist);
self.entries.insert(key.to_string(), Entry { data, modified, persist });
let version = self.next_version();
self.entries.insert(key.to_string(), Entry { data, modified, persist, version });
if persist || was_persisted {
self.touch(key);
}
@@ -86,6 +93,16 @@ impl Mirror {
}
}
fn next_version(&mut self) -> u64 {
self.version = self.version.wrapping_add(1);
self.version
}
/// A write of `key` that failed: the next flush writes the key's latest state again (#215).
pub fn retry(&mut self, key: &str) {
self.touch(key);
}
fn touch(&mut self, key: &str) {
if !self.dirty.iter().any(|k| k == key) {
self.dirty.push(key.to_string());
@@ -172,6 +189,11 @@ impl Store {
self.lock().get(&file_key(path)).map(|e| e.data.clone())
}
/// The file under `path` with its [`Entry::version`].
pub fn file_versioned(&self, path: &str) -> Option<(Arc<[u8]>, u64)> {
self.lock().get(&file_key(path)).map(|e| (e.data.clone(), e.version))
}
pub fn remove_file(&self, path: &str) -> bool {
let r = self.lock().remove(&file_key(path));
self.changed();
@@ -412,6 +434,38 @@ mod tests {
assert_eq!(m.list("files").len(), 1);
}
/// A file replaced by different bytes of the same size gets a new version, so workers are
/// sent it again (#218).
#[test]
fn versions_change_with_every_write() {
let s = store();
s.put_file("/collision.png", Arc::from(&b"red!"[..]), true);
let (_, a) = s.file_versioned("/collision.png").unwrap();
s.put_file("/collision.png", Arc::from(&b"blue"[..]), true);
let (data, b) = s.file_versioned("/collision.png").unwrap();
assert_ne!(a, b);
assert_eq!(&data[..], b"blue");
assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it");
}
/// A write that failed is flushed again with the key's latest state (#215).
#[test]
fn failed_writes_retry() {
let s = store();
s.put_file("/collision.png", Arc::from(&b"blue"[..]), true);
let mut m = s.lock();
let p = m.take_pending();
assert_eq!(p.len(), 1);
assert!(!m.has_pending());
m.retry(&p[0].key);
assert_eq!(m.take_pending(), p, "the same write again");
// A newer change wins over the failed one.
m.retry("files/collision.png");
m.put("files/collision.png", Arc::from(&b"gold"[..]), 2.0, true);
let again = m.take_pending();
assert_eq!((again.len(), again[0].data.as_deref()), (1, Some(&b"gold"[..])));
}
#[test]
fn keys_and_paths_round_trip() {
assert_eq!(file_key("/a b/c.mov"), "files/a b/c.mov");
+2 -2
View File
@@ -60,8 +60,8 @@ impl Offload for WorkerOffload {
let id = req.id;
let files = js_sys::Array::new();
for p in &req.files {
if let Some(d) = crate::files::get(p) {
files.push(&js_sys::Array::of2(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..])));
if let Some((d, version)) = crate::files::get_versioned(p) {
files.push(&js_sys::Array::of3(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..]), &JsValue::from_f64(version as f64)));
}
}
let json = serde_json::to_string(&req).map_err(|e| e.to_string())?;
+101
View File
@@ -0,0 +1,101 @@
// Job-worker plumbing of the web app (js/host.js, web/worker.js) under Node with a fake Worker,
// no browser or Wasm build needed:
//
// node apps/effectcraft-web/tests/workers.mjs
//
// - A reused worker is sent a file again when it was replaced, even by one of the same size
// (#218), and not when it is unchanged.
// - A worker whose start fails fails its job and is terminated, instead of the job waiting for
// "ready" forever (#216): host.js on the worker's error, worker.js re-raising the failed start.
// Exits non-zero on failure.
import assert from "node:assert/strict";
import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const tick = () => new Promise((r) => setTimeout(r, 0));
globalThis.self = globalThis;
// A Worker that starts (or fails to) on "init" and finishes each job at once.
let failStart = false;
const workers = [];
class FakeWorker {
constructor() {
this.posted = [];
this.terminated = false;
workers.push(this);
}
postMessage(m) {
this.posted.push(m);
setTimeout(() => {
if (m.type === "init") {
if (failStart) this.onerror?.({ message: "worker failed to start" });
else this.onmessage?.({ data: { type: "ready" } });
} else if (m.type === "job") {
this.onmessage?.({ data: { type: "reply", json: "{}", done: true } });
}
});
}
terminate() {
this.terminated = true;
}
}
globalThis.Worker = FakeWorker;
const host = await import(pathToFileURL(join(here, "../js/host.js")));
const run = (id, files) =>
new Promise((resolve) => host.workerRun(id, "{}", files, "http://localhost/", (kind, json) => kind !== "file" && resolve({ kind, json })));
const sent = (w) => w.posted.filter((m) => m.type === "file").map((m) => m.bytes[0]);
// #218: equal-length replacement.
const red = new Uint8Array(512).fill(1);
const blue = new Uint8Array(512).fill(2);
assert.equal((await run(1, [["/collision.png", red, 1]])).kind, "reply");
assert.equal((await run(2, [["/collision.png", blue, 2]])).kind, "reply");
assert.equal((await run(3, [["/collision.png", blue, 2]])).kind, "reply");
assert.equal(workers.length, 1, "the worker is reused");
assert.deepEqual(sent(workers[0]), [1, 2], "the replacement is sent once, the unchanged file not again");
// #216: a worker that fails to start.
failStart = true;
const slow = run(4, []); // the idle worker takes this one
const failed = await run(5, []);
await slow;
assert.equal(failed.kind, "error");
assert.ok(workers[1].terminated, "the failed worker is terminated");
assert.equal(host.workerStats().running, 0);
// #216, worker side: a rejected start is re-raised where the page's `onerror` sees it.
const dir = mkdtempSync(join(tmpdir(), "ec-worker-"));
copyFileSync(join(here, "../web/worker.js"), join(dir, "worker.js"));
writeFileSync(
join(dir, "effectcraft_web.js"),
`export default async function init() {}
export function workerInit() {}
export async function workerJobInit() { throw new Error("no device"); }
export async function workerFrameInit() {}`,
);
const posted = [];
globalThis.postMessage = (m) => posted.push(m);
// A browser's Worker `error` event only sees thrown errors, not unhandled rejections (Node would
// turn one into an uncaught exception unless it is listened to).
const unhandled = [];
process.on("unhandledRejection", (e) => unhandled.push(e));
const raised = new Promise((resolve) => {
process.once("uncaughtException", resolve);
setTimeout(() => resolve(new Error("nothing was raised")), 1000);
});
await import(pathToFileURL(join(dir, "worker.js")));
self.onmessage({ data: { type: "init", base: "http://localhost/" } });
const err = await raised;
await tick();
// (So a failed assertion below fails the run.)
process.removeAllListeners("unhandledRejection");
process.removeAllListeners("uncaughtException");
assert.deepEqual(unhandled, [], "the failed start is handled, not left as an unhandled rejection");
assert.equal(err.message, "no device");
assert.ok(!posted.some((m) => m.type === "ready"), "no ready after a failed start");
console.log("workers: ok");
+3
View File
@@ -30,6 +30,9 @@ self.onmessage = (e) => {
else await ec.workerJobInit(!!m.gpu);
self.postMessage({ type: "ready" });
})();
// A failed start is re-raised outside the promise so the page's `onerror` sees it: its job
// fails instead of waiting for "ready" forever (#216).
ready.catch((err) => setTimeout(() => { throw err; }));
return;
}
// A failure (a panic) is re-raised outside the queue so the page's `onerror` sees it, as
+116 -20
View File
@@ -2,6 +2,9 @@
//!
//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [project.ecproj | media files…]`
//!
//! Without a project the app starts with an empty Untitled Project, as After Effects does;
//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).
//!
//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
//! see `effectcraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
@@ -32,28 +35,43 @@ fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// The command line.
#[derive(Debug, Default, PartialEq)]
struct Args {
control_port: Option<u16>,
files: Vec<String>,
/// Open the demo project when no project is given (`--demo`).
demo: bool,
/// Show or skip the Home screen (`--home`), else the Startup preference decides.
home: Option<bool>,
}
/// Parse the arguments after the program name; `None` for `--version`. `env_port` is
/// `EFFECTCRAFT_CONTROL_PORT`.
fn parse_args(args: impl IntoIterator<Item = String>, env_port: Option<String>) -> Option<Args> {
let mut out = Args { control_port: env_port.and_then(|p| p.parse().ok()), ..Default::default() };
let mut args = args.into_iter();
while let Some(a) = args.next() {
match a.as_str() {
"--control" => out.control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => out.demo = true,
"--empty" => out.demo = false,
"--home" => out.home = Some(true),
"--version" => return None,
_ => out.files.push(a),
}
}
Some(out)
}
fn main() -> eframe::Result {
// Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report.
effectcraft_engine::logging::install();
effectcraft_engine::logging::install_panic_hook();
let mut control_port: Option<u16> = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut demo = true;
let mut home: Option<bool> = None;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--demo" => demo = true,
"--empty" => demo = false,
"--home" => home = Some(true),
"--version" => {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ => files.push(a),
}
}
let Some(Args { control_port, files, demo, home }) = parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT").ok()) else {
println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
};
// The font menus list the installed fonts: read their names while the window opens.
effectcraft_engine::text::fonts::scan_system_in_background();
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
@@ -71,9 +89,10 @@ fn main() -> eframe::Result {
// Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID.
.with_app_id(appimage::APP_ID),
event_loop_builder: agent_event_loop(control_port.is_some()),
wgpu_options: wgpu_options(),
..Default::default()
};
eframe::run_native(
let result = eframe::run_native(
"EffectCraft",
options,
Box::new(move |cc| {
@@ -170,7 +189,50 @@ fn main() -> eframe::Result {
app,
}))
}),
)
);
if let Err(e) = &result {
startup_failed(e);
}
result
}
/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px)
/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
/// #198) still opens the app, and the compositor then checks what it needs and falls back to
/// the CPU when the device can't run it.
fn wgpu_options() -> eframe::WgpuConfiguration {
use eframe::egui_wgpu::WgpuSetup;
let mut config = eframe::WgpuConfiguration::default();
if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor {
label: Some("EffectCraft device"),
required_limits: device_limits(adapter.limits()),
..Default::default()
});
}
config
}
/// The limits to request from an adapter that offers `adapter`: all of them, with textures
/// capped at 16384 px like the headless renderer's device.
fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter }
}
/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting
/// silently, since release builds on Windows have no console.
fn startup_failed(e: &eframe::Error) {
let message = format!(
"EffectCraft couldn't start its window: {e}\n\nUpdating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
);
log::error!("{message}");
eprintln!("effectcraft: {message}");
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("EffectCraft")
.set_description(message)
.set_buttons(rfd::MessageButtons::Ok)
.show();
}
/// The desktop app: the egui app plus, on macOS, the system menu bar.
@@ -227,3 +289,37 @@ fn disable_app_nap() {
std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason));
}
}
#[cfg(test)]
mod tests {
use super::{Args, parse_args};
fn parse(args: &[&str]) -> Option<Args> {
parse_args(args.iter().map(|a| a.to_string()), None)
}
/// The window's device never asks for more than the adapter offers (#198).
#[test]
fn device_limits_fit_the_adapter() {
use eframe::wgpu::Limits;
for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] {
let l = super::device_limits(adapter.clone());
assert!(l.check_limits(&adapter), "{l:?}");
assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage);
}
let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
}
/// Launching without a project opens an empty project, not the demo (#204).
#[test]
fn no_arguments_start_an_empty_project() {
assert_eq!(parse(&[]), Some(Args::default()));
assert!(parse(&["--demo"]).is_some_and(|a| a.demo));
assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo));
let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default();
assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()]));
assert_eq!(parse(&["--version"]), None);
assert_eq!(parse_args(Vec::new(), Some("9000".into())).map(|a| a.control_port), Some(Some(9000)));
}
}
+3
View File
@@ -1107,6 +1107,9 @@ pub const TIME_DEPENDENT: &[&str] = &[
"ec.obsolete.lightning",
"ec.text.timecode",
"ec.text.numbers",
// The audio visualisers read the Audio Layer's samples around the frame time.
"ec.generate.audiospectrum",
"ec.generate.audiowaveform",
// Path Text's jitter changes every frame.
"ec.obsolete.pathtext",
// Time effects read neighbouring frames of the layer.
+6 -1
View File
@@ -136,7 +136,12 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
return s.execute("keys.delete", json!({}));
}
if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() {
// (Points selected with their mask, which selecting a mask does: the mask goes, below.)
let comp = s.active_comp();
let loose = s.state.selected_vertices.iter().any(|v| {
!s.state.selected_props.contains(&(v.layer, v.mask)) && comp.and_then(|c| c.layer(v.layer)).is_some_and(|l| l.props.find_group(v.mask).is_some())
});
if loose && p.get("layers").is_none() {
return s.execute("mask.deleteVertices", json!({}));
}
// Puppet pins selected → delete the pins (not their layer).
+1 -1
View File
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Other ArtCraft Apps",
[],
None,
"{app: photocraft|vectorcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}",
"{app: photocraft|vectorcraft|filmcraft|lightcraft|pdfcraft|designcraft, kind?: page|github}",
always,
sibling
),
+3 -1
View File
@@ -1075,7 +1075,9 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
"polygon" => build::shape_star(&mut ids, false, 5.0, [0.0, 0.0], 100.0, 0.0),
"fill" => build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]),
"stroke" => build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 2.0),
"path" => build::shape_path(&mut ids, Default::default()),
"gfill" | "gradientFill" => build::shape_gradient_fill(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default()),
"gstroke" | "gradientStroke" => build::shape_gradient_stroke(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default(), 2.0),
"trim" | "trimPaths" => build::shape_trim(&mut ids, 0.0, 100.0, 0.0),
"repeater" => build::shape_repeater(&mut ids, 3.0, [100.0, 0.0]),
k => build::shape_simple_op(&mut ids, k).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{k}`")))?,
@@ -1404,7 +1406,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add (Shape)",
[],
None,
"{layer?, kind: group|rect|ellipse|star|polygon|fill|stroke|gfill|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
has_layers,
add_shape_item
),
+1 -1
View File
@@ -54,7 +54,7 @@ pub mod roto_cmds;
mod scene_detect;
pub mod scripts;
mod settings;
mod shape_stroke;
pub mod shape_stroke;
mod stubs;
mod styles;
mod text_anim;
+27 -2
View File
@@ -1,12 +1,12 @@
//! Properties, keyframes and expressions (Animation menu + timeline/Effect Controls gestures).
use effectcraft_keyframe::{Ease, Interp, Keyframe, easy_ease, key_at, set_key};
use effectcraft_project::{ItemId, LayerId, Property, Uid};
use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p};
use crate::{EngineError, KeyRef, Result, Session, cmd, query};
use crate::{EngineError, KeyRef, Result, Session, VertexRef, cmd, query};
/// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid).
pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
@@ -293,11 +293,19 @@ fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
let add = b_p(p, "add").unwrap_or(false);
if !add {
s.state.selected_props.clear();
s.state.selected_vertices.clear();
}
if !s.state.selected_layers.contains(&lid) {
s.state.selected_layers = vec![lid];
}
s.state.selected_props.push((lid, uid));
// Selecting a mask (or its Mask Path, or a shape's Path) selects all its points, so the
// viewer drags the whole mask (as in AE, #203).
let t = s.time();
if let Some((mask, n)) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| path_points(l, uid, l.layer_time(t))) {
s.state.selected_vertices.retain(|v| !(v.layer == lid && v.mask == mask));
s.state.selected_vertices.extend((0..n).map(|index| VertexRef { layer: lid, mask, index }));
}
// Selecting a property selects all its keys (as in AE).
if b_p(p, "selectKeys").unwrap_or(true)
&& let Some(pr) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid))
@@ -311,6 +319,23 @@ fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// The mask or shape Path item that `uid` (the group, or its Path property) is, with its number
/// of points at layer time `lt`.
fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> {
let owner = match l.props.find_group(uid) {
Some(g) => g,
None => l.props.parent_of(uid).filter(|g| g.get("path").is_some_and(|p| p.uid == uid))?,
};
let in_contents = owner.match_id == "path" && l.props.sub("contents").is_some_and(|c| c.find_group(owner.uid).is_some());
if !matches!(owner.kind, GroupKind::Mask { .. }) && !in_contents {
return None;
}
match owner.get("path")?.value_at(lt) {
effectcraft_keyframe::Value::Path(p) => Some((owner.uid, p.vertices.len())),
_ => None,
}
}
// ---------------------------------------------------------------- keyframes
fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
+4 -3
View File
@@ -26,8 +26,9 @@ fn pt(v: Option<&Value>) -> Option<[f64; 2]> {
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])
}
/// The Puppet effect's input pixels and evaluated parameters at comp time `t`.
pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) -> Option<(Buf, Params)> {
/// The Puppet effect's input pixels and evaluated parameters at comp time `t`. The input comes
/// from the layer cache, shared: moving pins doesn't change it (#212).
pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) -> Option<(std::sync::Arc<Buf>, Params)> {
let comp = s.project.comp(cid)?;
let layer = comp.layer(lid)?;
let fx = layer.effects()?;
@@ -37,7 +38,7 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick)
let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default());
r.expr = s.expr.as_deref();
r.cache = Some(&s.layer_cache);
let buf = (*r.layer_input(&ctx, layer, index)?).clone();
let buf = r.layer_input(&ctx, layer, index)?;
let params = effectcraft_effects::flatten_params(g, &mut |p| ctx.value(layer, p));
Some((buf, params))
}
+113 -3
View File
@@ -1,14 +1,16 @@
//! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and
//! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the
//! commands here set several of them in one undo step and add the groups to strokes saved before
//! they existed.
//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
use effectcraft_project::{ItemId, LayerId, PropGroup, Uid};
use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layer_mut, layer_p, merge_p};
use super::layer::color_p;
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p};
use crate::{EngineError, Result, Session, cmd};
fn is_stroke(g: &PropGroup) -> bool {
@@ -195,8 +197,116 @@ fn wave(s: &mut Session, p: &Value) -> Result<Value> {
)
}
/// Path items a Fill or Stroke paints in their group.
const PATHS: [&str; 4] = ["rect", "ellipse", "star", "path"];
/// Shape groups nest; deeper contents are left alone (never-crash recursion bound).
const MAX_DEPTH: usize = 64;
/// The first Fill colour, Stroke colour and Stroke Width in `layer`'s contents at layer time
/// `lt` (what the toolbar shows for a selected shape layer).
pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4]>, Option<f64>) {
let (mut fill, mut stroke, mut width) = (None, None, None);
if let Some(c) = layer.props.sub("contents") {
walk_groups(c, &mut |g| {
let at = |id: &str| g.get(id).map(|p| p.value_at(lt));
let rgba = |v: KV| v.as_color().map(f64::from);
match g.match_id.as_str() {
"fill" if fill.is_none() => fill = at("color").map(rgba),
"stroke" if stroke.is_none() => {
stroke = at("color").map(rgba);
width = at("width").map(|v| v.as_f64());
}
_ => {}
}
});
}
(fill, stroke, width)
}
/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and
/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before
/// its fills, as new shapes have it).
#[allow(clippy::too_many_arguments)]
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option<f64>, add_stroke: bool, depth: usize) {
if depth > MAX_DEPTH {
return;
}
if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len());
c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into());
}
for n in &mut c.children {
let Node::Group(g) = n else { continue };
let set = |g: &mut PropGroup, id: &str, v: Option<KV>| {
if let (Some(v), Some(p)) = (v, g.get_mut(id)) {
p.set_value_at(lt, v);
}
};
match g.match_id.as_str() {
"fill" => set(g, "color", fill.map(KV::Color)),
"stroke" => {
set(g, "color", stroke.map(KV::Color));
set(g, "width", width.map(KV::Scalar));
}
"gstroke" => set(g, "width", width.map(KV::Scalar)),
"group" => {
if let Some(sub) = g.sub_mut("contents") {
paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1);
}
}
_ => {}
}
}
}
/// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they
/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's,
/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that
/// have no stroke one. Without values, reports the first layer's paint.
fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.fillStroke";
let (cid, lids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let shapes: Vec<LayerId> = lids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape))).collect();
if shapes.is_empty() {
return Err(bad(c, "select a shape layer"));
}
let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
let (fill, stroke) = (rgba("fill"), rgba("stroke"));
let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0));
if fill.is_some() || stroke.is_some() || width.is_some() {
let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0);
let t = s.time();
s.edit("Fill and Stroke", merge_p(p), |proj, _| {
let mut next = proj.next_id;
for lid in &shapes {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
if let Some(contents) = l.props.sub_mut("contents") {
paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0);
}
}
proj.next_id = next;
Ok(())
})?;
}
let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?;
let (fill, stroke, width) = first_paint(l, l.layer_time(s.time()));
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"shape.fillStroke",
"Fill and Stroke",
[],
None,
"{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)",
has_layers,
fill_stroke
),
cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add),
cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove),
cmd!(
+1 -1
View File
@@ -15,7 +15,7 @@ pub const SIBLINGS: &[(&str, &str)] = &[
("VectorCraft", "vectorcraft"),
("FilmCraft", "filmcraft"),
("LightCraft", "lightcraft"),
("PrintCraft", "printcraft"),
("PdfCraft", "pdfcraft"),
("DesignCraft", "designcraft"),
];
+65
View File
@@ -183,6 +183,71 @@ fn text_shape_mask_matte_parent() {
assert_eq!(info["layers"].as_array().unwrap().len(), 2);
}
/// The toolbar's Fill / Stroke change a selected shape layer's paint (#205): a shape drawn
/// without a stroke gets one, later edits change it, and the Contents "Add:" items (#206) include
/// an empty Path and a Gradient Stroke that render.
#[test]
fn toolbar_fill_and_stroke_paint_selected_shape_layers() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let paint = |s: &mut Session| s.execute("shape.fillStroke", json!({})).unwrap();
assert_eq!(paint(&mut s)["stroke"], serde_json::Value::Null, "drawn without a stroke");
s.execute("shape.fillStroke", json!({"stroke": "#00ff00", "strokeWidth": 6})).unwrap();
let p = paint(&mut s);
assert_eq!((p["stroke"][1].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(6.0)), "{p}");
s.execute("shape.fillStroke", json!({"fill": "#0000ff", "strokeWidth": 3})).unwrap();
let p = paint(&mut s);
assert_eq!((p["fill"][2].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(3.0)), "{p}");
// One stroke, before the fill (paths, stroke, fill), and each change is one undo step.
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone();
let group = l.props.group("contents/group").unwrap();
let order: Vec<&str> = group.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect();
assert_eq!(order, ["rect", "stroke", "fill"]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(paint(&mut s)["strokeWidth"].as_f64(), Some(6.0));
// Only shape layers.
s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap();
assert!(s.execute("shape.fillStroke", json!({"fill": "#ffffff"})).is_err());
for kind in ["path", "gstroke"] {
let r = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": kind})).unwrap();
assert!(r["path"].as_str().is_some_and(|p| p.starts_with("contents/")), "{r}");
}
let cid = s.active_comp_id().unwrap();
let _ = s.render(cid, s.time(), Default::default());
}
/// Selecting a mask in the Timeline (its row or its Mask Path) selects all of its points, so the
/// viewer drags the whole mask (#203); a shape's Path item works the same way.
#[test]
fn selecting_a_mask_selects_its_points() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
let m1 = s.execute("layer.addMask", json!({"layer": l, "shape": "rect"})).unwrap()["mask"].as_u64().unwrap();
let m2 = s.execute("layer.addMask", json!({"layer": l, "shape": "ellipse"})).unwrap()["mask"].as_u64().unwrap();
let points = |s: &Session, m: u64| s.state.selected_vertices.iter().filter(|v| v.mask == m).count();
s.execute("prop.select", json!({"layer": l, "prop": m1, "selectKeys": false})).unwrap();
assert_eq!((points(&s, m1), s.state.selected_vertices.len()), (4, 4));
// Mask Path selects them too; Shift-adding another mask keeps the first.
let path = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().props.find_group(m2).unwrap().get("path").unwrap().uid;
s.execute("prop.select", json!({"layer": l, "prop": path, "add": true})).unwrap();
assert_eq!((points(&s, m1), points(&s, m2)), (4, 4));
// Another mask replaces them; another property deselects them.
s.execute("prop.select", json!({"layer": l, "prop": m2})).unwrap();
assert_eq!((points(&s, m1), points(&s, m2)), (0, 4));
// Delete removes the selected mask (not only its points).
s.execute("edit.clear", json!({})).unwrap();
let masks = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().masks().unwrap().children.len();
assert_eq!(masks(&s), 1);
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
assert!(s.state.selected_vertices.is_empty());
let sh = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap();
let star = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "path"})).unwrap()["uid"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": sh, "prop": star})).unwrap();
assert_eq!(s.state.selected_vertices.len(), 0, "an empty Path has no points");
}
#[test]
fn save_and_open_roundtrip() {
let mut s = demo();
+28 -5
View File
@@ -492,7 +492,10 @@ pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bo
/// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here
/// (keyed by item and source time), since Time effects read many neighbouring frames.
pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, effects: usize) -> Option<u64> {
let base = key_with(ctx, layer, scale, draft, blur, true)?;
if layer.switches.adjustment {
return None;
}
let base = key_any(ctx, layer, scale, draft, blur, true, Some(effects))?;
let mut h = KeyHasher(base ^ 0x5bd1_e995_7a3c_11d3);
effects.hash(&mut h);
Some(h.finish())
@@ -516,18 +519,20 @@ fn has_expression(g: &PropGroup) -> bool {
/// Cache key for an adjustment layer's footprint (its source through its masks, see
/// `Renderer::adjustment_footprint`): the GPU compositor then uploads it once, not every frame.
pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option<u64> {
Some(derive(key_any(ctx, layer, scale, draft, blur, false)?, 0xf007_9417))
Some(derive(key_any(ctx, layer, scale, draft, blur, false, None)?, 0xf007_9417))
}
fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option<u64> {
if layer.switches.adjustment {
return None;
}
key_any(ctx, layer, scale, draft, blur, footage)
key_any(ctx, layer, scale, draft, blur, footage, None)
}
/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`).
fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option<u64> {
/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`). `effects`: key only
/// the first that many effects (a layer's input, see [`input_key`]).
#[allow(clippy::too_many_arguments)]
fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool, effects: Option<usize>) -> Option<u64> {
let mut h = KeyHasher(0xcbf2_9ce4_8422_2325);
match &layer.source {
LayerSource::Solid { item } => {
@@ -576,6 +581,24 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
{
continue;
}
// A layer's input runs only its first effects: the later ones (a Puppet whose pins are
// dragged, #212) don't change it, so it stays cached.
if let (Some(n), Node::Group(g)) = (effects, c)
&& g.match_id == "effects"
{
let mut first = g.clone();
let mut seen = 0;
first.children.retain(|e| match e {
Node::Prop(_) => true,
Node::Group(_) => {
seen += 1;
seen <= n
}
});
hash_debug(&mut h, &first);
hash_values(&mut h, ctx, layer, &first);
continue;
}
// Static structure: values, keyframes, expressions, enabled flags, effect ids, modes.
hash_debug(&mut h, c);
if let Node::Group(g) = c {
+39
View File
@@ -214,3 +214,42 @@ fn peak_summary_bins() {
assert_eq!(peaks.len(), 200);
assert!((peaks[150][1] - 1.51).abs() < 0.01, "{:?}", peaks[150]);
}
/// Audio Spectrum reads its Audio Layer's samples at the frame time, so the layer cache keys it
/// by time: a cached frame after the sound starts matches an uncached render (#209).
#[test]
fn audio_spectrum_follows_time_through_the_layer_cache() {
fn late_tone(t: f64) -> (f32, f32) {
let v = if t < 0.5 { 0.0 } else { (2.0 * std::f64::consts::PI * 1000.0 * t).sin() as f32 };
(v, v)
}
let mut p = Project::default();
let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
let audio = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(audio_footage()));
let a = build::layer(&mut p, &comp, "a", LayerSource::Footage { item: audio }, (64, 64), None);
let sid = p.add_item("S", effectcraft_color::Label::Red, None, ItemKind::Solid(Solid { color: [0.0; 3], width: 64, height: 64, pixel_aspect: 1.0 }));
let mut s = build::layer(&mut p, &comp, "s", LayerSource::Solid { item: sid }, (64, 64), None);
let spec = effectcraft_effects::find("ec.generate.audiospectrum").unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 64.0]);
p.next_id = next;
g.prop_mut("audioLayer").unwrap().value = Value::Layer(Some(a.id.0));
s.props.sub_mut("effects").unwrap().children.push(g.into());
let mut comp = comp;
comp.layers = vec![s, a];
let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
let src = Signal(late_tone);
let render = |t: f64, cache: Option<&crate::LayerCache>| {
let mut r = crate::Renderer::new(&p, &src, crate::RenderOpts::default());
r.cache = cache;
r.comp_frame(cid, Tick::from_seconds_f64(t))
};
let lit = |img: &Image| img.data.iter().filter(|px| px[0] + px[1] + px[2] > 0.1).count();
let cache = crate::LayerCache::default();
let silent = render(0.0, Some(&cache));
let cached = render(0.8, Some(&cache));
let fresh = render(0.8, None);
assert!(lit(&fresh) > lit(&silent), "the tone draws more than the baseline: {} vs {}", lit(&fresh), lit(&silent));
assert_eq!(cached.data, fresh.data, "cached {} px, uncached {} px", lit(&cached), lit(&fresh));
}
+28
View File
@@ -103,3 +103,31 @@ fn cache_correct_while_scrubbing() {
assert_eq!(render(&p, cid, t, Some(&cache)).data, render(&p, cid, t, None).data, "edited t={t}");
}
}
/// A layer's input up to an effect keys only the effects before it: editing a later effect (a
/// Puppet pin being dragged, #212) keeps the input cached; editing an earlier one doesn't.
#[test]
fn input_keys_ignore_later_effects() {
let (mut p, cid) = scene(Some(("ec.blur.gaussian", &[("blurriness", Value::Scalar(4.0))])));
{
let spec = effectcraft_effects::find("ec.stylize.mosaic").unwrap();
let mut next = p.next_id;
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]);
p.next_id = next;
p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(g.into());
}
let key = |p: &Project| {
let comp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx { project: p, comp_id: cid, comp, time: Tick::from_seconds_f64(0.5), expr: None, footage: None };
let l = &comp.layers[0];
(crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2))
};
let (one, two) = key(&p);
assert!(one.is_some() && one != two);
let set = |p: &mut Project, path: &str, v: f64| p.comp_mut(cid).unwrap().layers[0].props.prop_mut(path).unwrap().value = Value::Scalar(v);
set(&mut p, "effects/#2/horizontal", 30.0);
assert_eq!(key(&p).0, one, "the second effect doesn't change the input to it");
assert_ne!(key(&p).1, two);
set(&mut p, "effects/#1/blurriness", 8.0);
assert_ne!(key(&p).0, one);
}
+69
View File
@@ -425,6 +425,55 @@ pub fn to_color_image(img: &effectcraft_engine::render::Image) -> egui::ColorIma
egui::ColorImage::new([img.width as usize, img.height as usize], px)
}
/// The size `size` is shown at in a texture whose sides can't exceed `max_side`: divided by the
/// smallest whole factor that fits (frames of comps wider than the GPU's texture limit, #201).
pub fn fitted_size(size: [usize; 2], max_side: usize) -> [usize; 2] {
let k = size[0].max(size[1]).div_ceil(max_side.max(1)).max(1);
[size[0].div_ceil(k), size[1].div_ceil(k)]
}
/// `img` as a texture the renderer accepts: unchanged when it fits in `max_side`, else averaged
/// down by a whole factor (premultiplied pixels average correctly). The viewer draws it over the
/// same area, so only detail beyond the GPU's texture limit is lost, instead of the upload
/// failing (#201).
pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage {
let [w, h] = img.size;
let [fw, fh] = fitted_size(img.size, max_side);
if [fw, fh] == [w, h] {
return img;
}
let k = w.max(h).div_ceil(max_side.max(1)).max(1);
let px: Vec<egui::Color32> = (0..fw * fh)
.into_par_iter()
.map(|i| {
let (x0, y0) = ((i % fw) * k, (i / fw) * k);
let (mut sum, mut n) = ([0u32; 4], 0u32);
for y in y0..(y0 + k).min(h) {
for c in img.pixels.get(y * w + x0..y * w + (x0 + k).min(w)).unwrap_or_default() {
for (s, v) in sum.iter_mut().zip(c.to_array()) {
*s += v as u32;
}
n += 1;
}
}
let n = n.max(1);
let [r, g, b, a] = sum.map(|s| ((s + n / 2) / n) as u8);
egui::Color32::from_rgba_premultiplied(r, g, b, a)
})
.collect();
egui::ColorImage::new([fw, fh], px)
}
/// The largest texture side the renderer accepts.
pub fn max_texture_side(ctx: &egui::Context) -> usize {
ctx.input(|i| i.max_texture_side)
}
/// `ctx.load_texture` for frame-sized images: fitted to the renderer's texture limit first.
pub fn load_fitted(ctx: &egui::Context, name: impl Into<String>, img: egui::ColorImage, opts: egui::TextureOptions) -> egui::TextureHandle {
ctx.load_texture(name, fit_texture(img, max_texture_side(ctx)), opts)
}
impl Frames {
/// Retire only GPU preview state; documents, CPU frames and running job leases survive.
/// Publication checks the same latch while holding the cache lock, so a late GPU frame
@@ -1015,6 +1064,26 @@ impl Queue {
mod tests {
use super::*;
/// Frames larger than the texture limit are averaged down by a whole factor (#201: an
/// 11000×2200 comp at Full resolution against an 8192 limit).
#[test]
fn frames_over_the_texture_limit_are_averaged_down() {
assert_eq!(fitted_size([11000, 2200], 8192), [5500, 1100]);
assert_eq!(fitted_size([8192, 100], 8192), [8192, 100]);
assert_eq!(fitted_size([30000, 30000], 16384), [15000, 15000]);
assert_eq!(fitted_size([0, 0], 0), [0, 0]);
// 5×2 (three columns of white, then black) into 2 px: 3×2 blocks (the last one 2×2).
let w = egui::Color32::WHITE;
let b = egui::Color32::from_rgba_premultiplied(0, 0, 0, 255);
let img = egui::ColorImage::new([5, 2], vec![w, w, w, b, b, w, w, w, b, b]);
let out = fit_texture(img.clone(), 2);
assert_eq!(out.size, [2, 1]);
assert_eq!(out.pixels, vec![w, b]);
assert_eq!(fit_texture(img.clone(), 5).pixels, img.pixels);
let mixed = fit_texture(egui::ColorImage::new([2, 1], vec![w, b]), 1);
assert_eq!(mixed.pixels, vec![egui::Color32::from_rgba_premultiplied(128, 128, 128, 255)]);
}
#[test]
fn retirement_reaches_existing_workers_and_keeps_cpu_frames_and_completion_live() {
let frames = Frames::default();
+47 -3
View File
@@ -9,6 +9,7 @@ use crate::state::Tool;
use crate::theme::Tokens;
use crate::widgets;
use crate::{Dialog, EffectcraftApp};
use effectcraft_engine::project::{LayerId, LayerSource};
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
@@ -91,7 +92,40 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Tool options.
x += 10.0;
if app.ui.tool.is_shape() || app.ui.tool == Tool::Pen {
// Selected shape layers: the options show their paint and edit it (as in After Effects).
let shape_layers: Vec<u64> = app
.session
.active_comp()
.map(|c| {
app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect()
})
.unwrap_or_default();
if let Some(l) = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))) {
let (fill, stroke, width) = effectcraft_engine::commands::shape_stroke::first_paint(l, l.layer_time(app.session.time()));
let rgb = |c: [f64; 4]| [c[0] as f32, c[1] as f32, c[2] as f32];
if let Some(c) = fill {
app.ui.fill_color = rgb(c);
}
if let Some(c) = stroke {
app.ui.stroke_color = rgb(c);
}
app.ui.stroke_width = width.unwrap_or(0.0) as f32;
}
// One undo step per picker session or width drag: end the merge once both are done.
let picking = ["tool-fill-pop", "tool-stroke-pop"].iter().any(|k| ui.data(|d| d.get_temp::<bool>(egui::Id::new(*k).with("open")).unwrap_or(false)));
if !picking && !ui.input(|i| i.pointer.any_down()) && app.session.history.merge_key.as_deref().is_some_and(|k| k.starts_with("tool-")) {
app.session.history.merge_key = None;
}
if app.ui.tool.is_shape() || app.ui.tool == Tool::Pen || !shape_layers.is_empty() {
let paint = |app: &mut EffectcraftApp, key: &str, v: serde_json::Value| {
if shape_layers.is_empty() {
return;
}
let merge = format!("tool-{key}");
if let Err(e) = app.session.execute("shape.fillStroke", serde_json::json!({"layers": shape_layers, key: v, "merge": merge})) {
app.ui.status = e.to_string();
}
};
p.text(pos2(x, cy), Align2::LEFT_CENTER, "Fill:", Tokens::ui(12.0), t.text_dim);
x += 28.0;
let fr = Rect::from_center_size(pos2(x + 10.0, cy), vec2(20.0, 16.0));
@@ -99,7 +133,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if widgets::swatch(ui, fr, [c[0], c[1], c[2], 1.0], egui::Id::new("tool-fill"), &t).clicked() {
widgets::open_popup(ui, egui::Id::new("tool-fill-pop"));
}
color_popup(ui, egui::Id::new("tool-fill-pop"), fr.left_bottom(), &mut app.ui.fill_color);
if color_popup(ui, egui::Id::new("tool-fill-pop"), fr.left_bottom(), &mut app.ui.fill_color) {
let c = app.ui.fill_color;
paint(app, "fill", serde_json::json!(c));
}
app.auto.add("header.fill", fr, "Fill Color");
x += 32.0;
p.text(pos2(x, cy), Align2::LEFT_CENTER, "Stroke:", Tokens::ui(12.0), t.text_dim);
x += 44.0;
@@ -108,13 +146,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if widgets::swatch(ui, sr, [c[0], c[1], c[2], 1.0], egui::Id::new("tool-stroke"), &t).clicked() {
widgets::open_popup(ui, egui::Id::new("tool-stroke-pop"));
}
color_popup(ui, egui::Id::new("tool-stroke-pop"), sr.left_bottom(), &mut app.ui.stroke_color);
if color_popup(ui, egui::Id::new("tool-stroke-pop"), sr.left_bottom(), &mut app.ui.stroke_color) {
let c = app.ui.stroke_color;
paint(app, "stroke", serde_json::json!(c));
}
app.auto.add("header.stroke", sr, "Stroke Color");
x += 26.0;
let (r, v, _) =
widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new("tool-stroke-w"), app.ui.stroke_width as f64, 0.2, (0.0, 1000.0), 0, " px", &t);
if let Some(v) = v {
app.ui.stroke_width = v as f32;
paint(app, "strokeWidth", serde_json::json!(v));
}
app.auto.add("header.strokeWidth", r, "Stroke Width");
x = r.max.x + 14.0;
}
if app.ui.tool.puppet_kind().is_some() {
+22 -3
View File
@@ -139,7 +139,8 @@ pub struct Playback {
pub audio_frame: Option<i64>,
/// The preview's sound waits for frames: while the frames ahead aren't cached, every frame
/// shows as it renders, silently; the sound starts once the rest of the preview (or what
/// fits in the RAM preview) is cached, so it never plays over skipped frames (#103).
/// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or
/// sooner once the silent preview keeps real time with the frames ahead cached (#208).
pub audio_held: bool,
/// When the last frames were shown (seconds, the last second's worth): the achieved rate.
pub shown_at: std::collections::VecDeque<f64>,
@@ -168,6 +169,15 @@ impl Playback {
let step = self.plan.map_or(1, |p| p.step).max(1) as f64;
self.achieved_fps().map(|f| f * step >= self.fps * 0.95)
}
/// The preview has kept real time for the last quarter second (at least two frames) up to
/// `now`: rendering keeps up, so sound can start without waiting for the whole preview.
pub fn keeps_up(&self, now: f64) -> bool {
let step = self.plan.map_or(1, |p| p.step).max(1) as f64;
let frame = step / self.fps.max(1.0);
let (Some(a), Some(b)) = (self.shown_at.front(), self.shown_at.back()) else { return false };
self.shown_at.len() >= 3 && b - a >= (2.0 * frame).max(0.25) && now - b <= 2.0 * frame && self.real_time() == Some(true)
}
}
pub struct EffectcraftApp {
@@ -566,6 +576,11 @@ impl EffectcraftApp {
self.gpu.as_ref().map(effectcraft_engine::render::Accelerator::name)
}
/// The size of the viewer frame's texture (CPU frames; fitted to the GPU's texture limit).
pub fn viewer_texture_size(&self) -> Option<[usize; 2]> {
self.viewer_tex.as_ref().map(|(t, _)| t.size())
}
/// The viewer's current pixels as 8-bit premultiplied RGBA, reading a GPU frame back the
/// first time something asks for it.
///
@@ -1016,10 +1031,12 @@ impl EffectcraftApp {
return;
}
let snap = |f: i64| wa + (f - wa).div_euclid(step) * step;
// Held sound starts once the frames ahead are cached (see `Playback::audio_held`).
// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the
// prefetch window when the silent preview keeps real time, else the rest of the preview.
let fit = self.frames_that_fit(&c, scale);
if self.playback.audio_held
&& let Some(p) = plan
&& self.cached_ahead(&p, &series, cur, self.frames_that_fit(&c, scale))
&& ((self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, ahead.min(fit))) || self.cached_ahead(&p, &series, cur, fit))
{
self.playback.audio_held = false;
self.playback.start_wall = now;
@@ -1052,6 +1069,8 @@ impl EffectcraftApp {
self.audio = None;
self.playback.audio_held = true;
self.playback.waiting = true;
// The rate measured before the stall no longer says it keeps up.
self.playback.shown_at.clear();
self.playback.start_wall = now;
self.playback.start_frame = cur;
}
+32 -1
View File
@@ -190,6 +190,35 @@ fn reveal_time_remap(app: &mut EffectcraftApp, params: &Value) {
}
}
/// After Alt+Shift+P (A, S, R, T) the Timeline shows the property on each layer it keyed, as in
/// After Effects: added to what a reveal shortcut shows, else with the layer's Transform twirled
/// open, else alone like P.
fn reveal_keyed(app: &mut EffectcraftApp, keyed: &Value, kind: &str) {
let layers: std::collections::BTreeSet<u64> = keyed.as_array().into_iter().flatten().filter_map(|k| k.get("layer").and_then(Value::as_u64)).collect();
let Some(comp) = app.session.active_comp() else { return };
let tl = &mut app.ui.timeline;
for id in layers {
let shown = tl.layer_reveal.get(&id).filter(|k| !k.is_empty()).cloned();
match shown {
Some(mut kinds) => {
if !kinds.iter().any(|k| k == kind) {
kinds.push(kind.to_string());
tl.layer_reveal.insert(id, kinds);
}
}
None if tl.open_layers.contains(&id) => {
if let Some(tr) = comp.layer(effectcraft_engine::project::LayerId(id)).and_then(|l| l.transform()) {
tl.open_groups.insert(tr.uid);
}
}
None => {
tl.open_layers.insert(id);
tl.layer_reveal.insert(id, vec![kind.to_string()]);
}
}
}
}
/// The layers a reveal shortcut acts on: the selected ones, else every layer of the active comp.
fn reveal_targets(app: &EffectcraftApp) -> Vec<u64> {
if app.session.state.selected_layers.is_empty() {
@@ -314,7 +343,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
return Ok(Value::Null);
}
if let Some(k) = id.strip_prefix("timeline.keyAt.") {
return run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k}));
let r = run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k}))?;
reveal_keyed(app, &r, k);
return Ok(r);
}
if let Some(k) = id.strip_prefix("timeline.revealAdd.") {
reveal(app, k, now, true);
+1 -1
View File
@@ -58,7 +58,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Some((k, tex, size)) if k == key => Some((tex, size)),
_ => app.session.footage.frame(view.item, f, view.time).map(|img| {
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8());
let tex = ctx.load_texture("footage-panel", ci, egui::TextureOptions::LINEAR);
let tex = crate::frames::load_fitted(&ctx, "footage-panel", ci, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height])));
(tex, [img.width, img.height])
}),
+1 -1
View File
@@ -147,7 +147,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let x0 = -buf.offset[0] / buf.scale;
let y0 = -buf.offset[1] / buf.scale;
let rect = [x0, y0, x0 + buf.img.width as f64 / buf.scale, y0 + buf.img.height as f64 / buf.scale];
let tex = ctx.load_texture("layer-panel", img, egui::TextureOptions::LINEAR);
let tex = crate::frames::load_fitted(&ctx, "layer-panel", img, egui::TextureOptions::LINEAR);
let c = Tex { key, tex, rect };
ctx.data_mut(|d| d.insert_temp(tex_id(), c.clone()));
c
+39 -3
View File
@@ -323,9 +323,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h));
let lp = p.with_clip_rect(list);
// The list's empty area (under the rows and the scroll bar, which take their own clicks):
// a double-click imports, as in After Effects (File ▸ Import ▸ File...).
let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click());
app.auto.add("project.empty", list, "Double-click to import files; right-click for project actions");
// a click deselects, a drag draws a selection box, a double-click imports, as in After
// Effects (File ▸ Import ▸ File...).
let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click_and_drag());
app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions");
if overflow > 0.0 && ui.rect_contains_pointer(list) {
let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
let d = if dx.abs() > 0.0 {
@@ -730,6 +731,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
});
// Selection box over the rows it touches (#203); Shift or Cmd/Ctrl adds to the selection.
let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
if empty.clicked() && !adding {
app.session.state.project_selection.clear();
}
let box_id = egui::Id::new("proj-marquee");
if let (Some(start), Some(end)) = (ctx.input(|i| i.pointer.press_origin()), empty.interact_pointer_pos())
&& empty.dragged()
{
let b = Rect::from_two_pos(start, end).intersect(list);
lp.rect_filled(b, 0.0, t.accent.gamma_multiply(0.12));
lp.rect_stroke(b, 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
ctx.data_mut(|d| d.insert_temp(box_id, b));
}
if empty.drag_stopped()
&& let Some(b) = ctx.data(|d| d.get_temp::<Rect>(box_id))
{
ctx.data_mut(|d| d.remove::<Rect>(box_id));
// Rows sit at fixed heights from the top of the scrolled list.
let top = list.min.y - app.ui.project_scroll;
let picked = rows.iter().enumerate().filter(|(i, _)| {
let y = top + *i as f32 * ROW_H;
y < b.max.y && y + ROW_H > b.min.y
});
let picked: Vec<ItemId> = picked.map(|(_, (id, _))| *id).collect();
let sel = &mut app.session.state.project_selection;
if !adding {
sel.clear();
}
for id in picked {
if !sel.contains(&id) {
sel.push(id);
}
}
}
for (id, params) in actions {
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
app.ui.status = e;
+1 -1
View File
@@ -122,7 +122,7 @@ pub fn draw_overlay(
let x0 = -inp.offset[0] / inp.scale;
let y0 = -inp.offset[1] / inp.scale;
let rect = [x0, y0, x0 + w as f64 / inp.scale, y0 + h as f64 / inp.scale];
let tex = ctx.load_texture("roto-overlay", img, egui::TextureOptions::NEAREST);
let tex = crate::frames::load_fitted(ctx, "roto-overlay", img, egui::TextureOptions::NEAREST);
let c = Overlay { key, tex, rect };
ctx.data_mut(|d| d.insert_temp(id, c.clone()));
Some(c)
+166 -11
View File
@@ -47,6 +47,16 @@ enum RowKind {
/// Synthetic group uid for a layer's Audio > Waveform twirl (never a real property uid).
const WAVE_BIT: u64 = 1 << 60;
/// Synthetic group uid for the Geometry Options row of a 3D text or shape layer in a Classic 3D
/// comp, which only offers "Change Renderer…" (never a real property uid).
const GEOM_BIT: u64 = 1 << 59;
/// Classic 3D comps don't extrude: a 3D text or shape layer's Geometry Options row offers to
/// change the renderer instead, as in After Effects.
fn classic_geometry_row(comp: &Comp, l: &Layer) -> bool {
comp.renderer == effectcraft_engine::project::Renderer::Classic3D && l.is_3d() && matches!(l.source, LayerSource::Text | LayerSource::Shape)
}
/// Time navigator's visible-span bar and the work area bar.
const NAV_BAR: Color32 = Color32::from_rgb(0x55, 0x55, 0x55);
const WORK_AREA_BAR: Color32 = Color32::from_rgb(0x5c, 0x5c, 0x5c);
@@ -597,8 +607,11 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
reveal_rows(app, l, kinds, &mut rows);
continue;
}
let classic_geometry = classic_geometry_row(comp, l);
for c in &l.props.children {
match c {
// The Change Renderer row (after Transform) stands in for the extrusion options.
Node::Group(g) if classic_geometry && g.match_id == "geometryOptions" => {}
Node::Group(g) if group_visible(g, l) => {
let open = tl.open_groups.contains(&g.uid);
rows.push(Row {
@@ -631,6 +644,20 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
}
}
}
if classic_geometry && g.match_id == "transform" {
rows.push(Row {
layer: l.id,
depth: 1,
kind: RowKind::Group {
uid: GEOM_BIT | l.id.0,
name: "Geometry Options".into(),
open: false,
has_children: false,
fx: None,
eye: None,
},
});
}
}
Node::Prop(p) if prop_visible(p, l) => rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }),
_ => {}
@@ -1704,6 +1731,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
shape_add_popup(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
if *uid == GEOM_BIT | layer.id.0 {
let lr = lp.text(pos2(cw.switches + 6.0, cy), Align2::LEFT_CENTER, "Change Renderer…", Tokens::ui(11.5), t.accent);
let resp =
ui.interact(lr, egui::Id::new(("tl-change-renderer", layer.id.0)), Sense::click()).on_hover_text("Composition Settings ▸ 3D Renderer");
if resp.hovered() {
ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
}
app.auto.add(&format!("timeline.layer.{}.changeRenderer", layer.id.0), lr, "Change Renderer…");
if resp.clicked() {
ui_actions.push(UiAct::ChangeRenderer);
}
}
dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
// Mask mode + inverted inline.
if let Some(g) = layer.props.find_group(*uid)
@@ -1727,7 +1767,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
let gr = ui.interact(gr_rect, egui::Id::new(("grow", uid)), Sense::click());
app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
if gr.clicked() && uid & WAVE_BIT == 0 {
if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
}
if gr.double_clicked() {
@@ -2208,9 +2248,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if graph_on {
super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions);
}
// Empty-area click in the graph: deselect keys; drag: box-select keys.
// Empty-area click in the graph: deselect keys (below the last row, the layers too, as in
// After Effects); drag: box-select keys.
if empty.clicked() {
actions.push(("keys.select".into(), json!({"keys": []})));
let below = empty.interact_pointer_pos().is_some_and(|p| hit_rows.iter().all(|(r, _)| p.y > r.max.y));
if below && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
actions.push(("layer.select".into(), json!({"layers": []})));
}
}
if let (true, Some(origin), Some(cur)) =
(empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos())
@@ -2477,6 +2522,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
UiAct::SetTime(tt) => app.session.set_time(tt),
UiAct::EndMerge => app.session.history.merge_key = None,
UiAct::ChangeRenderer => {
if super::dialogs::open_comp_settings(app).is_ok() {
app.dialog_state.comp.tab = super::comp_settings::Tab::Renderer;
}
}
}
}
if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") {
@@ -2594,6 +2644,8 @@ enum UiAct {
ToggleGroup(u64),
SetTime(Tick),
EndMerge,
/// Composition Settings on its 3D Renderer tab.
ChangeRenderer,
}
fn walk_groups(g: &PropGroup, out: &mut Vec<u64>) {
@@ -2634,6 +2686,75 @@ fn dash_buttons(
}
}
/// The shape items the Contents row's "Add:" menu offers, as `layer.addShapeItem` kinds ("-" is
/// a separator), in After Effects' order.
const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[
("group", "Group (empty)"),
("-", ""),
("rect", "Rectangle"),
("ellipse", "Ellipse"),
("star", "Polystar"),
("path", "Path"),
("-", ""),
("fill", "Fill"),
("stroke", "Stroke"),
("gfill", "Gradient Fill"),
("gstroke", "Gradient Stroke"),
("-", ""),
("merge", "Merge Paths"),
("offset", "Offset Paths"),
("pucker", "Pucker & Bloat"),
("repeater", "Repeater"),
("round", "Round Corners"),
("trim", "Trim Paths"),
("twist", "Twist"),
("wiggle", "Wiggle Paths"),
("zigzag", "Zig Zag"),
];
/// A shape layer's Contents "Add:" pop-up menu: adds a shape, paint or path operation to the
/// selected shape group, else to the layer's contents.
#[allow(clippy::too_many_arguments)]
fn shape_add_popup(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
uid: u64,
x: f32,
cy: f32,
actions: &mut Vec<(String, serde_json::Value)>,
) {
if !matches!(layer.source, LayerSource::Shape) || layer.props.sub("contents").is_none_or(|c| c.uid != uid) {
return;
}
let t = app.tokens;
p.text(pos2(x + 6.0, cy), Align2::LEFT_CENTER, "Add:", Tokens::ui(11.5), t.text_dim);
let br = Rect::from_center_size(pos2(x + 40.0, cy), vec2(16.0, 16.0));
let resp = ui.interact(br, egui::Id::new(("tl-shapeadd", uid)), Sense::click()).on_hover_text("Add a shape, fill, stroke or path operation");
icons::paint(p, br.shrink(3.0), Icon::ChevronRight, if resp.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("timeline.group.{uid}.add"), br, "Add:");
let pop = egui::Id::new(("tl-shapeadd-pop", uid));
if resp.clicked() {
widgets::open_popup(ui, pop);
}
let opts: Vec<String> = SHAPE_ADD_ITEMS.iter().map(|(k, l)| if *k == "-" { "-".to_string() } else { l.to_string() }).collect();
if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &opts, None)
&& let Some((kind, _)) = SHAPE_ADD_ITEMS.get(i).filter(|(k, _)| *k != "-")
{
let mut params = json!({"layer": layer.id.0, "kind": kind});
// Into the selected shape group, as After Effects does.
let group = app.session.state.selected_props.iter().filter(|(l, _)| *l == layer.id).find_map(|(_, u)| {
let g = layer.props.find_group(*u)?;
(g.match_id == "group" && g.sub("contents").is_some()).then_some(g.uid)
});
if let Some(g) = group {
params["group"] = json!(g);
}
actions.push(("layer.addShapeItem".into(), params));
}
}
/// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus).
#[allow(clippy::too_many_arguments)]
fn text_anim_popups(
@@ -2794,6 +2915,26 @@ fn fmt_num(v: f64, d: usize) -> String {
format!("{v:.d$}")
}
/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio
/// (Constrain Proportions); from 0 the others that are 0 too follow the value.
fn constrained(c: &[f64], d: usize, v: f64) -> Vec<f64> {
let Some(&old) = c.get(d) else { return c.to_vec() };
c.iter()
.enumerate()
.map(|(e, &x)| {
if e == d {
v
} else if old.abs() > 1e-9 {
x * v / old
} else if x.abs() <= 1e-9 {
v
} else {
x
}
})
.collect()
}
/// Inline value editor for a property row; pushes `prop.set` actions.
fn value_editor(
app: &mut EffectcraftApp,
@@ -2841,21 +2982,35 @@ fn value_editor(
let c = value.components();
let n = if prop.shown_dims > 0 && !(is_3d && c.len() == 3) { prop.shown_dims as usize } else { c.len() };
let pct = matches!(prop.ui, ParamUi::Percent);
// Mask Feather: two linked values, never negative (as in After Effects, #203).
let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. }));
let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) };
// Scale and Mask Feather: the chain link (Constrain Proportions, on by default).
let linkable = feather || (pct && prop.match_id == "scale");
let linked = linkable && !app.ui.timeline.unlinked.contains(&uid);
if linkable {
let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0));
let resp = ui.interact(lr, egui::Id::new(("tl-link", uid)), Sense::click()).on_hover_text("Constrain Proportions");
icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions");
if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) {
app.ui.timeline.unlinked.insert(uid);
}
x += 18.0;
}
for d in 0..n.min(c.len()) {
let suffix = if pct && d + 1 == n { "%" } else { "" };
let (r, nv, _) =
widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, (-1e9, 1e9), 1, suffix, &t);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t);
app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
if let Some(nv) = nv {
let mut nc = c.clone();
if pct && prop.match_id == "scale" && !ui.input(|i| i.modifiers.alt) {
// Constrain proportions (AE's chain link, on by default).
let k = if c[d].abs() > 1e-9 { nv / c[d] } else { 1.0 };
for e in 0..n {
nc[e] = if e == d { nv } else { c[e] * k };
// Alt edits one value of a linked pair.
let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 };
let from = if shown == 1 { d } else { 0 };
for (e, v) in constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
if let Some(slot) = nc.get_mut(from + e) {
*slot = v;
}
} else {
nc[d] = nv;
}
set(actions, json!(nc));
}
+8 -5
View File
@@ -397,7 +397,7 @@ fn aux_views(
_ => {
let opts = effectcraft_engine::render::RenderOpts { scale, view: comp.has_3d().then_some(cam), draft: true, ..Default::default() };
let img = app.session.render(cid, t, opts);
let tex = ctx.load_texture(format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR);
let tex = crate::frames::load_fitted(&ctx, format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
@@ -459,7 +459,7 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo
dc.apply(&mut px);
ci = egui::ColorImage::new(ci.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect());
}
let tex = ctx.load_texture("viewer-locked", ci, egui::TextureOptions::LINEAR);
let tex = crate::frames::load_fitted(&ctx, "viewer-locked", ci, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
@@ -1912,12 +1912,15 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames:
let opts = zoom_texture_options(smooth);
match img {
FrameImage::Cpu(img) => {
// Frames wider or taller than the GPU's texture limit go up averaged down (#201).
let max = crate::frames::max_texture_side(ctx);
let fitted = crate::frames::fit_texture((*img).clone(), max);
match &mut app.viewer_tex {
Some((tex, k)) if tex.size() == img.size => {
tex.set((*img).clone(), opts);
Some((tex, k)) if tex.size() == fitted.size => {
tex.set(fitted, opts);
*k = key;
}
_ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), opts), key)),
_ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", fitted, opts), key)),
}
app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k)| (t.id(), *k));
app.viewer_image = Some(img);
+2 -2
View File
@@ -382,7 +382,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
_ => {
let img = crate::frames::to_color_image(&s.image);
let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure, dc) };
let tex = ctx.load_texture("viewer-snapshot", img, egui::TextureOptions::LINEAR);
let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
@@ -432,7 +432,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), zoom_opts);
let tex = crate::frames::load_fitted(ctx, "viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), zoom_opts);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
+5 -3
View File
@@ -131,13 +131,15 @@ fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: Fram
}
let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
let tex = match (img, app.passive_tex.remove(&id)) {
(FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h)))) if h.size() == img.size => {
h.set((*img).clone(), opts);
(FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h))))
if h.size() == crate::frames::fitted_size(img.size, crate::frames::max_texture_side(ctx)) =>
{
h.set(crate::frames::fit_texture((*img).clone(), crate::frames::max_texture_side(ctx)), opts);
PassiveTexture::Cpu(h)
}
(FrameImage::Cpu(img), old) => {
free(app, old.map(|(_, t)| t));
PassiveTexture::Cpu(ctx.load_texture(format!("viewer-{id}"), (*img).clone(), opts))
PassiveTexture::Cpu(crate::frames::load_fitted(ctx, format!("viewer-{id}"), (*img).clone(), opts))
}
(FrameImage::Gpu(f), old) => {
let Some(rs) = &app.wgpu else { return };
+2 -2
View File
@@ -108,10 +108,10 @@ fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) {
let opts = crate::panels::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
let t = match prev {
Some((_, mut t)) => {
t.set((*img).clone(), opts);
t.set(crate::frames::fit_texture((*img).clone(), crate::frames::max_texture_side(ctx)), opts);
t
}
None => ctx.load_texture("video-preview", (*img).clone(), opts),
None => crate::frames::load_fitted(ctx, "video-preview", (*img).clone(), opts),
};
ctx.data_mut(|d| d.insert_temp(tex_id, (k, t.clone())));
Some(t)
+5
View File
@@ -413,6 +413,10 @@ pub struct TimelineState {
/// Properties whose inline expression editor is collapsed.
#[serde(default)]
pub expr_closed: BTreeSet<u64>,
/// Scale and Mask Feather properties whose chain link (Constrain Proportions, on by default)
/// was turned off.
#[serde(default)]
pub unlinked: BTreeSet<u64>,
/// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`,
/// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name
/// column is always shown; Modes follows `show_modes` (F4).
@@ -458,6 +462,7 @@ impl Default for TimelineState {
graph_reference: false,
graph_transform_box: true,
expr_closed: BTreeSet::new(),
unlinked: BTreeSet::new(),
columns: default_tl_columns(),
source_name: false,
}
+11 -1
View File
@@ -283,10 +283,20 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String]
if !open {
return None;
}
// As tall as the window allows (a shape layer's Add menu has 20 entries), scrolling beyond,
// and moved up when it would run past the bottom of the window (the Timeline's menus).
let screen = ui.ctx().content_rect();
let max_h = (screen.height() - 24.0).max(120.0);
let sp = ui.spacing();
let seps = options.iter().filter(|o| *o == "-").count() as f32;
let rows = options.len() as f32 - seps;
let est = (rows * (sp.interact_size.y + sp.item_spacing.y + 4.0) + seps * (2.0 * sp.item_spacing.y + 2.0) + 16.0).min(max_h + 12.0);
let pos = egui::pos2(pos.x, pos.y.min(screen.bottom() - est).max(screen.top()));
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(160.0);
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
// (An area's content is laid out in last frame's size: ask for the estimate.)
egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| {
for (i, o) in options.iter().enumerate() {
if o == "-" {
ui.separator();
+41 -3
View File
@@ -30,10 +30,15 @@ impl AudioDevice for Fake {
}
}
/// A 30 fps comp whose solid sounds a tone (Audio switch on).
/// A 2 s, 30 fps comp whose solid sounds a tone (Audio switch on).
fn setup(audible: bool, fake: &Fake) -> (EffectcraftApp, ItemId) {
setup_long(audible, fake, 2.0)
}
/// A `duration`-second, 30 fps comp whose solid sounds a tone (Audio switch on).
fn setup_long(audible: bool, fake: &Fake, duration: f64) -> (EffectcraftApp, ItemId) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "A", "width": 64, "height": 36, "frameRate": 30, "duration": 2})).unwrap();
s.execute("comp.new", json!({"name": "A", "width": 64, "height": 36, "frameRate": 30, "duration": duration})).unwrap();
let cid = s.active_comp_id().unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
if audible {
@@ -140,8 +145,41 @@ fn preview_with_audio_shows_every_frame_and_sounds_once_cached() {
}
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached");
assert!(shown > 0, "frames played silently first");
// A frame that isn't cached stops the sound rather than being skipped.
// A frame that isn't cached stops the sound rather than being skipped. (Frames render fast
// enough here to be cached again before the check: keep them out of the cache.)
h.state().frames.set_budget(0);
h.state().frames.clear();
h.step();
assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing);
}
/// A preview whose frames render in real time sounds without waiting for the whole work area to
/// be cached (#208): once the silent preview has kept up for a moment with the frames ahead
/// cached, the sound starts, still without skipping a frame before it.
#[test]
fn preview_with_audio_sounds_once_rendering_keeps_up() {
let fake = Fake::default();
let (app, cid) = setup_long(true, &fake, 30.0);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
h.run_steps(2);
let frame = |h: &Harness<'_, EffectcraftApp>| {
let app = h.state();
app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time())
};
let now = h.ctx.input(|i| i.time);
h.state_mut().play(now);
assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet");
let mut last = frame(&h);
// Two seconds of playback, each step giving the frame workers time to keep up.
for _ in 0..60 {
h.step();
if h.state().audio.is_some() {
break;
}
let f = frame(&h);
assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped");
last = f;
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started within 2 s, at frame {last} of 900");
}
+24
View File
@@ -95,3 +95,27 @@ fn label_swatch_opens_the_label_menu() {
assert_eq!(h.state().session.history.undo.len(), undo + 1);
assert!(h.query_by_label(&green).is_none(), "the menu closed after the choice");
}
/// Dragging from the Project panel's empty area draws a selection box that selects the rows it
/// touches; a click there deselects (#203).
#[test]
fn dragging_in_the_empty_area_box_selects_items() {
let (mut h, folder, _, solid) = harness();
let list = h.state().auto.find("project.empty").unwrap().rect;
let from = pos2(list[0] + 40.0, list[1] + list[3] - 6.0);
let to = center(&h, &format!("project.item.{}.name", folder.0));
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
button(&mut h, from, true);
h.step();
for k in 1..=6 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0)));
h.step();
}
button(&mut h, to, false);
h.run_steps(2);
let sel = h.state().session.state.project_selection.clone();
assert!(sel.contains(&folder) && sel.contains(&solid), "{sel:?}");
click_at(&mut h, from);
assert!(h.state().session.state.project_selection.is_empty());
}
+154
View File
@@ -0,0 +1,154 @@
//! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic
//! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the
//! layers deselecting them (#205), and Mask Feather's linked values (#203).
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
h
}
fn session() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
s
}
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
}
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let p = center(h, id);
click_at(h, p);
}
fn open_layer(h: &mut Harness<'_, EffectcraftApp>, id: u64) {
h.state_mut().ui.timeline.open_layers.insert(id);
h.run_steps(2);
}
#[test]
fn shape_contents_add_menu_adds_path_operations() {
let mut s = session();
let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap();
let contents = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().uid;
let mut h = harness(s);
open_layer(&mut h, l);
click(&mut h, &format!("timeline.group.{contents}.add"));
let entry = h.get_by_label("Trim Paths").rect();
assert!(entry.max.y <= 1000.0, "the whole menu fits in the window: {entry:?}");
click_at(&mut h, entry.center());
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let items: Vec<&str> = layer.props.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect();
assert_eq!(items, ["group", "trim"]);
}
#[test]
fn classic_3d_shape_layers_offer_change_renderer() {
let mut s = session();
let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
let mut h = harness(s);
open_layer(&mut h, l);
click(&mut h, &format!("timeline.layer.{l}.changeRenderer"));
assert_eq!(h.state().dialog, Some(Dialog::CompSettings));
assert!(h.state().auto.find("dialog.comp.renderer").is_some(), "on the 3D Renderer tab");
// Advanced 3D shows the extrusion options instead.
h.state_mut().dialog = None;
h.state_mut().session.execute("comp.renderer", json!({"renderer": "advanced3d"})).unwrap();
h.run_steps(3);
assert!(h.state().auto.find(&format!("timeline.layer.{l}.changeRenderer")).is_none());
}
#[test]
fn add_position_key_reveals_position() {
let mut s = session();
let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
let mut h = harness(s);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.keyAt.position", json!({})).unwrap();
h.run_steps(2);
let app = h.state();
let position = app.session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone();
assert_eq!(position.keys.len(), 1);
assert_eq!(app.ui.timeline.layer_reveal.get(&l), Some(&vec!["position".to_string()]));
assert!(app.auto.find(&format!("timeline.prop.{}.value.0", position.uid)).is_some(), "the Position row shows");
}
#[test]
fn clicking_below_the_layers_deselects_them() {
let mut s = session();
let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
let mut h = harness(s);
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(l)]);
// In the time graph, below the only layer.
let bar = center(&h, &format!("timeline.layer.{l}.bar"));
click_at(&mut h, bar + egui::vec2(0.0, 80.0));
assert!(h.state().session.state.selected_layers.is_empty());
}
#[test]
fn mask_feather_values_are_linked_and_not_negative() {
let mut s = session();
let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.addMask", json!({"layer": l, "shape": "rect"})).unwrap();
let feather = |h: &Harness<'_, EffectcraftApp>| {
let p = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("masks/#1/feather").unwrap().clone();
(p.uid, p.value.components())
};
let mut h = harness(s);
h.state_mut().ui.timeline.open_layers.insert(l);
h.state_mut().ui.timeline.layer_reveal.insert(l, vec!["feather".into()]);
h.run_steps(2);
let (uid, _) = feather(&h);
let type_value = |h: &mut Harness<'_, EffectcraftApp>, d: usize, v: &str| {
click(h, &format!("timeline.prop.{uid}.value.{d}"));
h.input_mut().events.push(Event::Text(v.into()));
h.step();
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
};
type_value(&mut h, 0, "12");
assert_eq!(feather(&h).1, vec![12.0, 12.0], "linked by default");
type_value(&mut h, 1, "-5");
assert_eq!(feather(&h).1, vec![0.0, 0.0], "never negative");
click(&mut h, &format!("timeline.prop.{uid}.link"));
type_value(&mut h, 1, "7");
assert_eq!(feather(&h).1, vec![0.0, 7.0], "unlinked");
}
#[test]
fn toolbar_stroke_edits_the_selected_shape_layer() {
let mut s = session();
let l = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let mut h = harness(s);
// The Selection tool is active: the options show for the selected shape layer.
click(&mut h, "header.strokeWidth");
h.input_mut().events.push(Event::Text("4".into()));
h.step();
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let width = layer.props.prop("contents/group/contents/stroke/width").map(|p| p.value.as_f64());
assert_eq!(width, Some(4.0), "a stroke was added with the toolbar's width");
assert_eq!(h.state().ui.stroke_width, 4.0);
}
+23
View File
@@ -1019,3 +1019,26 @@ fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() {
drag_path(&mut h, &path, egui::Modifiers::SHIFT);
assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h));
}
/// A comp wider than the GPU's texture limit at Full resolution shows its frame (averaged down
/// into a texture the renderer accepts) instead of failing the upload (#201: 11000×2200).
#[test]
fn full_resolution_frames_wider_than_the_texture_limit_fit() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Wide", "width": 2400, "height": 400, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
// (egui's font atlas needs 1024.)
h.input_mut().max_texture_side = Some(1024);
h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Full;
let full = |h: &mut Harness<'_, EffectcraftApp>| h.state_mut().viewer_pixels().is_some_and(|px| px.size == [2400, 400]);
for _ in 0..400 {
h.step();
if full(&mut h) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().ui.viewer.res);
assert_eq!(h.state().viewer_texture_size(), Some([800, 134]), "2400×400 averaged by 3");
}
+2 -1
View File
@@ -491,7 +491,8 @@ not read or written.
## Seeing the UI
To work on the UI, start the app with `cargo run -p effectcraft -- --control 9877` and use MCP bridge
To work on the UI, start the app with `cargo run -p effectcraft -- --control 9877` (add `--demo` to
open the demo project; without it the app starts with an empty project) and use MCP bridge
mode or the raw control channel. A good loop is: `ui_elements` to find an id, `ui_click` or `ui_drag`
to act, then `screenshot {"panel":"Timeline"}` to check the result. `render_frame` shows the
composition itself at any zoom.
+11 -3
View File
@@ -34,7 +34,7 @@ of them have been reconciled.
| Behaves like After Effects | largely unmeasured, ≈ 60–80% | One outside contributor found four bugs in features marked done within a day (PRs #6–#10): Hold keyframes eased the motion into them, `keyInSpatialTangent` / `keyOutSpatialTangent` had the wrong names, a zero frame rate crashed, Find and Enter Full Screen shared Ctrl+F off macOS. There are about 10 After Effects reference captures in total. The first live G1 comparison now checks eight scalar Rotation/ease cases (168 samples) against AE 26.3x87 via AEsync 2.0.4: all pass after fixing overlapping temporal influences, with maximum error below 3e-11 degrees ([scores and reproduction](fidelity/README.md)). Other animation behavior and rendered frames remain unmeasured. User reports of 6 October found five more in features marked done (the Render Queue's template menu, angle revolutions, switches wiping the RAM preview, audio playback skipping frames, preview crashing when video memory ran out; all fixed 7 October) |
| Opening existing After Effects work | ≈ 0% | `.aep` / `.aepx` projects cannot be opened. Third-party After Effects plug-ins cannot run. Expressions and the scripting object model are strong, so scripts and expressions carry over |
| Stability | improving | 23 never-crash PRs landed on 4 October; community PRs on 7 October fixed a hang on projects whose parent chains loop (#142), a panic on non-ASCII label colours (#135), and contained GPU initialization and device failures (#139); [AGENTS.md](../AGENTS.md) "Never crash" now binds every crate. On 5 October `cargo xtask ci` failed on main under Rust 1.99's clippy (a fix is in progress) |
| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Most checking happens on macOS. No localisation, no accessibility work |
| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Most checking happens on macOS. No localisation, no accessibility work |
| Performance | unknown against After Effects | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU, an M4 Pro under load). Nothing benchmarked against After Effects; no large real projects (4K footage, hundreds of layers) tested |
| Media formats | ≈ 80% | H.264, ProRes, HEVC, AV1, image sequences and audio exist. The new HEVC / AV1 encoders have no B-frames, multi-reference or SAO / CDEF, so files are larger than from mature encoders. Camera formats (BRAW, R3D, ProRes RAW, variable-frame-rate phone video) are unverified |
| AI-assisted tools | ≈ 50% | Both tools can use trained models (pure-Rust inference, optional downloads), but nothing compares them with After Effects yet. Roto Brush 2.0 / 3.0 with MobileSAM (M13.35) scores IoU 0.989 on the base frame of our synthetic moving-disc test and ≥ 0.980 over 20 propagated frames (classic: 0.973). Face tracking with MediaPipe Face Landmarker (M13.36) matches Google's own pipeline to 0.85 px on average on a test portrait; on our synthetic clip the eyes and chin stay within 4% of the face height |
@@ -65,8 +65,8 @@ The most important missing piece: it turns every other estimate here into a meas
- Fix user issues as they come in, each with a regression test, and answer the reporter (#41–#47
and #63–#68 are handled, as are the Windows report that the font menus missed installed fonts
and the reports of 6 October, #85, #88, #89, #93, #103, #106 and #117; Wayland file drops wait
on winit).
and the reports of 6 October, #85, #88, #89, #93, #103, #106 and #117, and of 7 October, #198,
#201, #203–#206, #208, #209 and #212; Wayland file drops wait on winit).
- Run the headless snapshot and control-channel checks on Linux (X11 and Wayland) and Windows, not
only macOS. Interactions that only fail with real input (docking drags, viewer pan, drag-and-drop
import, inline rename) need scripted input tests.
@@ -90,6 +90,14 @@ The most important missing piece: it turns every other estimate here into a meas
Windows, verify that Escape cancels edits and non-finite entries leave the value and undo
history unchanged in Effect Controls, Timeline and Properties. Valid entries still commit on
Enter or click-away.
- Regression evidence for the reports of 7 October, checked headlessly on Linux: `ui_viewer`
`full_resolution_frames_wider_than_the_texture_limit_fit` (#201), the desktop app's
`no_arguments_start_an_empty_project` (#204) and `device_limits_fit_the_adapter` (#198),
`audio_spectrum_follows_time_through_the_layer_cache` (#209),
`preview_with_audio_sounds_once_rendering_keeps_up` (#208), `ui_timeline_shapes` (the Add menu
and Change Renderer, #206; deselecting below the layers, Alt+Shift+P and the toolbar's stroke,
#205; linked Mask Feather, #203), `dragging_in_the_empty_area_box_selects_items` and
`selecting_a_mask_selects_its_points` (#203), and `input_keys_ignore_later_effects` (#212).
- Done when: no open bug blocks a basic workflow (import, arrange, animate, preview, render) on
any of the three desktop platforms, and every bug users report gets triaged within a day.
+6 -1
View File
@@ -384,7 +384,7 @@ with the method's `result`, errors reject with the message.
| `effectcraft.saveToBrowser(path?)` | save the project to browser storage without downloading it (default: its path, or `/<name>.ecproj`); resolves with `{path, bytes}` |
| `effectcraft.listStored()` | `{backend, usage, quota, persisted, pending, files: [{path, size, modified}], config: [name]}` |
| `effectcraft.removeStored(path)` | delete a stored file |
| `effectcraft.flush()` | resolves once every change is written to browser storage |
| `effectcraft.flush()` | resolves once every change is written to browser storage; rejects when a write failed (quota exceeded…: the change stays pending and is retried) |
| `effectcraft.info()` | graphics backend, `gpu`, `storage`, `audio` (`{state, sampleRate, backend, posted, played, underruns}`), `workers`, `frameWorkers`, `diskCache`, `restored`, `webgpu`, `serviceWorker`, version, `crossOriginIsolated`, load timings |
| `effectcraft.workerFrameCheck(params)` | a frame rendered in a frame worker (its GPU by default) against the page's CPU render: `{width, height, maxDiff, meanDiff, over4, workerMs}` |
@@ -413,6 +413,11 @@ cargo xtask web --serve 8765 &
node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out target/web/smoke
```
`apps/effectcraft-web/tests/workers.mjs` checks the job-worker plumbing (`js/host.js`,
`web/worker.js`) under Node with a fake `Worker`, without a build: a replaced file is sent to a
reused worker again even at the same size, and a worker that fails to start fails its job and is
terminated (`node apps/effectcraft-web/tests/workers.mjs`).
It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the
CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that
replies when done while the page keeps running, and the Media Browser over browser storage; and