mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Merge branch 'worktree-agent-a087e9b59ccc99094'
This commit is contained in:
@@ -333,3 +333,158 @@ export function workerCancel(id) {
|
||||
export function workerStats() {
|
||||
return { running: running.size, idle: idle.length };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ frame workers
|
||||
|
||||
// Long-lived workers that render viewer frames from a project replica (src/frames.rs). Unlike job
|
||||
// workers they are never terminated between requests; the page sends one frame at a time each.
|
||||
const frameWorkers = [];
|
||||
|
||||
/// Start a frame worker; returns its index. `onMessage(index, json, bytes, error)` receives each
|
||||
/// `frameReply` (JSON, pixels as a Uint8Array) or, if the worker dies, the error.
|
||||
export function frameWorkerStart(base, onMessage) {
|
||||
const index = frameWorkers.length;
|
||||
const worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` });
|
||||
worker.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
|
||||
};
|
||||
worker.onerror = (e) => {
|
||||
onMessage(index, undefined, undefined, String(e.message || "frame worker failed"));
|
||||
worker.terminate();
|
||||
};
|
||||
worker.postMessage({ type: "init", module: self.effectcraftModule ?? null, base });
|
||||
frameWorkers.push(worker);
|
||||
return index;
|
||||
}
|
||||
|
||||
/// Post a message ({type: "frame", json} or {type: "file", path, bytes}) to frame worker `index`.
|
||||
export function frameWorkerPost(index, msg, transfer) {
|
||||
const w = frameWorkers[index];
|
||||
if (w) w.postMessage(msg, transfer ?? []);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ media browser
|
||||
|
||||
// Folders opened with the File System Access API (src/browse.rs): name → {handle, files: Map of
|
||||
// "name/sub/file" → FileSystemFileHandle}. Their handles are remembered in IndexedDB so the
|
||||
// folders come back on the next visit (once the browser grants access again).
|
||||
const folders = new Map();
|
||||
const HANDLES_DB = "effectcraft-handles";
|
||||
const MAX_ENTRIES = 20000;
|
||||
|
||||
async function handlesDb() {
|
||||
const open = indexedDB.open(HANDLES_DB, 1);
|
||||
open.onupgradeneeded = () => open.result.createObjectStore("folders", { keyPath: "name" });
|
||||
return req(open);
|
||||
}
|
||||
|
||||
async function enumerate(dir, prefix, out, files, depth) {
|
||||
for await (const [name, h] of dir.entries()) {
|
||||
if (name.startsWith(".") || out.length >= MAX_ENTRIES) continue;
|
||||
const rel = `${prefix}/${name}`;
|
||||
if (h.kind === "directory") {
|
||||
out.push({ path: rel, dir: true, size: 0, modified: 0 });
|
||||
if (depth < 8) await enumerate(h, rel, out, files, depth + 1);
|
||||
} else {
|
||||
const f = await h.getFile();
|
||||
out.push({ path: rel, dir: false, size: f.size, modified: Math.floor(f.lastModified / 1000) });
|
||||
files.set(rel, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openFolder(handle, remember) {
|
||||
const name = handle.name;
|
||||
const entries = [];
|
||||
const files = new Map();
|
||||
await enumerate(handle, name, entries, files, 0);
|
||||
folders.set(name, { handle, files });
|
||||
if (remember) {
|
||||
try {
|
||||
const db = await handlesDb();
|
||||
await req(db.transaction("folders", "readwrite").objectStore("folders").put({ name, handle }));
|
||||
} catch (e) { console.info("media browser: cannot remember the folder", e); }
|
||||
}
|
||||
return { name, entries };
|
||||
}
|
||||
|
||||
/// Whether the browser can open folders (File System Access API).
|
||||
export function browseCanPickFolder() {
|
||||
return typeof self.showDirectoryPicker === "function";
|
||||
}
|
||||
|
||||
/// Ask for a folder; resolves with {name, entries: [{path, dir, size, modified}]}.
|
||||
export async function browsePickFolder() {
|
||||
const handle = await self.showDirectoryPicker({ id: "effectcraft-media", mode: "read" });
|
||||
return openFolder(handle, true);
|
||||
}
|
||||
|
||||
/// Folders opened on earlier visits: [{name, granted, entries?}] (entries when access is granted).
|
||||
export async function browseRestore() {
|
||||
if (!self.indexedDB || !browseCanPickFolder()) return [];
|
||||
let all = [];
|
||||
try {
|
||||
const db = await handlesDb();
|
||||
all = await req(db.transaction("folders", "readonly").objectStore("folders").getAll());
|
||||
} catch { return []; }
|
||||
const out = [];
|
||||
for (const { name, handle } of all) {
|
||||
let granted = false;
|
||||
try { granted = (await handle.queryPermission({ mode: "read" })) === "granted"; } catch {}
|
||||
if (granted) {
|
||||
try { out.push({ granted, ...(await openFolder(handle, false)) }); continue; } catch {}
|
||||
}
|
||||
out.push({ name, granted: false });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Ask for access to a remembered folder again (needs a user gesture); resolves like
|
||||
/// browsePickFolder.
|
||||
export async function browseReconnect(name) {
|
||||
const db = await handlesDb();
|
||||
const rec = await req(db.transaction("folders", "readonly").objectStore("folders").get(name));
|
||||
if (!rec) throw new Error(`${name} is not remembered`);
|
||||
if ((await rec.handle.requestPermission({ mode: "read" })) !== "granted") throw new Error(`access to ${name} was not granted`);
|
||||
return openFolder(rec.handle, false);
|
||||
}
|
||||
|
||||
/// Forget a remembered folder.
|
||||
export async function browseForget(name) {
|
||||
folders.delete(name);
|
||||
try {
|
||||
const db = await handlesDb();
|
||||
await req(db.transaction("folders", "readwrite").objectStore("folders").delete(name));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/// A file of an opened folder ("/Folders/name/sub/file"): its bytes.
|
||||
export async function browseRead(path) {
|
||||
const rel = path.replace(/^\/Folders\//, "");
|
||||
const folder = folders.get(rel.split("/")[0]);
|
||||
const h = folder && folder.files.get(rel);
|
||||
if (!h) throw new Error(`${path}: its folder is not open (reconnect it)`);
|
||||
return new Uint8Array(await (await h.getFile()).arrayBuffer());
|
||||
}
|
||||
|
||||
/// The fallback where folders can't be opened: pick files (or a whole folder, `directory`) with a
|
||||
/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder).
|
||||
export function browsePickFiles(directory) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
if (directory) input.webkitdirectory = true;
|
||||
input.onchange = async () => {
|
||||
try {
|
||||
const out = [];
|
||||
for (const f of input.files) {
|
||||
out.push({ path: (directory && f.webkitRelativePath) || f.name, bytes: new Uint8Array(await f.arrayBuffer()) });
|
||||
}
|
||||
resolve(out);
|
||||
} catch (e) { reject(e); }
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
|
||||
use crate::{api, audio, files, persist, worker};
|
||||
use crate::{api, audio, browse, files, frames, persist, worker};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use serde_json::json;
|
||||
use wasm_bindgen::prelude::*;
|
||||
@@ -103,6 +103,7 @@ impl eframe::App for WebApp {
|
||||
persist::snapshot(&self.app.session, ctx.input(|i| i.time), 1.0);
|
||||
}
|
||||
api::set_info("gpu", json!({"compositor": self.app.gpu_adapter(), "viewerOnGpu": self.app.viewer_on_gpu()}));
|
||||
api::set_info("frameWorkers", frames::stats());
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
@@ -137,6 +138,7 @@ pub fn session() -> Session {
|
||||
exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })),
|
||||
config: Some(Arc::new(persist::config())),
|
||||
offload: (!query_flag("noworkers")).then(|| Arc::new(worker::WorkerOffload) as Arc<dyn effectcraft_engine::offload::Offload>),
|
||||
browser: Some(Arc::new(browse::WebBrowser)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -183,6 +185,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
};
|
||||
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0}));
|
||||
audio::install_unlock();
|
||||
browse::restore();
|
||||
let runner = eframe::WebRunner::new();
|
||||
runner
|
||||
.start(
|
||||
@@ -206,6 +209,12 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
app.offer_recovery(r);
|
||||
}
|
||||
install_hooks(&mut app);
|
||||
// Viewer frames render in frame workers (`?frameworkers=N`, 0 or `?noworkers`:
|
||||
// on the page's thread).
|
||||
let n = if query_flag("noworkers") { 0 } else { query_value("frameworkers").and_then(|v| v.parse().ok()).unwrap_or(2usize).min(8) };
|
||||
if n > 0 {
|
||||
app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n))));
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! The Media Browser in the browser ([`effectcraft_engine::media_browser::Browser`]).
|
||||
//!
|
||||
//! It browses a virtual tree:
|
||||
//!
|
||||
//! - **Browser Storage** (`/Browser Storage/…`): every file in the persistent file table (OPFS /
|
||||
//! IndexedDB): imported media, uploaded files and folders, saved projects. Files added with
|
||||
//! **Add Files…** / **Upload Folder…** (plain `<input type=file>`, so every browser) are copied
|
||||
//! there and stay across visits: the recent list.
|
||||
//! - **Folders** (`/Folders/<name>/…`): folders opened with **Open Folder…** through the File
|
||||
//! System Access API (`showDirectoryPicker`, Chromium browsers). Only their listing is read;
|
||||
//! a file's bytes are read when it is imported ([`Browser::fetch`]) and then kept in the file
|
||||
//! table. The folder handles are remembered (IndexedDB), so after a reload the folders come
|
||||
//! back once the browser grants access again (**Reconnect <name>**).
|
||||
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use effectcraft_engine::media_browser::{Browser, Entry, VirtualTree};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = browseCanPickFolder)]
|
||||
fn can_pick_folder() -> bool;
|
||||
#[wasm_bindgen(js_name = browsePickFolder)]
|
||||
fn pick_folder() -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = browseRestore)]
|
||||
fn restore_folders() -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = browseReconnect)]
|
||||
fn reconnect(name: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = browseForget)]
|
||||
fn forget(name: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = browseRead)]
|
||||
fn read(path: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = browsePickFiles)]
|
||||
fn pick_files(directory: bool) -> js_sys::Promise;
|
||||
}
|
||||
|
||||
const STORAGE: &str = "/Browser Storage";
|
||||
const FOLDERS: &str = "/Folders";
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// Opened folders' files (`/Folders/…`).
|
||||
folders: VirtualTree,
|
||||
/// Opened folder names, and remembered ones waiting for access.
|
||||
open: Vec<String>,
|
||||
disconnected: Vec<String>,
|
||||
}
|
||||
|
||||
static STATE: LazyLock<Mutex<State>> = LazyLock::new(Mutex::default);
|
||||
|
||||
fn state() -> std::sync::MutexGuard<'static, State> {
|
||||
STATE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// The web app's Media Browser.
|
||||
pub struct WebBrowser;
|
||||
|
||||
/// The stored files as a tree under `/Browser Storage` (rebuilt per listing: storage changes).
|
||||
fn storage_tree() -> VirtualTree {
|
||||
let mut t = VirtualTree::default();
|
||||
t.add_dir(STORAGE);
|
||||
for (path, size, modified, persisted) in crate::files::STORE.files() {
|
||||
if !persisted || path.starts_with(FOLDERS) {
|
||||
continue;
|
||||
}
|
||||
t.add_file(&format!("{STORAGE}{path}"), &path, size as u64, Some((modified / 1000.0) as u64));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Add an opened folder's listing (`[{path, dir, size, modified}]`, paths `name/…`).
|
||||
fn add_folder(v: &Value) -> Option<String> {
|
||||
let name = v["name"].as_str()?.to_string();
|
||||
let mut st = state();
|
||||
st.folders.remove(&format!("{FOLDERS}/{name}"));
|
||||
st.folders.add_dir(&format!("{FOLDERS}/{name}"));
|
||||
for e in v["entries"].as_array().into_iter().flatten() {
|
||||
let Some(rel) = e["path"].as_str() else { continue };
|
||||
let path = format!("{FOLDERS}/{rel}");
|
||||
if e["dir"].as_bool() == Some(true) {
|
||||
st.folders.add_dir(&path);
|
||||
} else {
|
||||
st.folders.add_file(&path, &path, e["size"].as_u64().unwrap_or(0), e["modified"].as_u64());
|
||||
}
|
||||
}
|
||||
st.open.retain(|n| *n != name);
|
||||
st.open.push(name.clone());
|
||||
st.disconnected.retain(|n| *n != name);
|
||||
Some(name)
|
||||
}
|
||||
|
||||
fn to_value(v: &JsValue) -> Value {
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// Run a promise; on success, `then` gets its value (as JSON) on the UI thread.
|
||||
fn spawn(p: js_sys::Promise, what: &'static str, then: impl FnOnce(&mut effectcraft_ui_egui::EffectcraftApp, Value) + 'static) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match wasm_bindgen_futures::JsFuture::from(p).await {
|
||||
Ok(v) => {
|
||||
let v = to_value(&v);
|
||||
crate::post(move |app| then(app, v));
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_default();
|
||||
// Cancelling a picker is not an error.
|
||||
if !msg.contains("abort") && !msg.contains("AbortError") {
|
||||
crate::post(move |app| app.ui.status = format!("{what}: {msg}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn go(app: &mut effectcraft_ui_egui::EffectcraftApp, path: &str) {
|
||||
if let Err(e) = app.session.execute("mediaBrowser.go", json!({"path": path})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring back the folders opened on earlier visits (those the browser still grants access to).
|
||||
pub fn restore() {
|
||||
spawn(restore_folders(), "Media Browser", |_, v| {
|
||||
for f in v.as_array().into_iter().flatten() {
|
||||
if f["granted"].as_bool() == Some(true) {
|
||||
add_folder(f);
|
||||
} else if let Some(n) = f["name"].as_str() {
|
||||
let mut st = state();
|
||||
if !st.disconnected.iter().any(|d| d == n) {
|
||||
st.disconnected.push(n.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl Browser for WebBrowser {
|
||||
fn home(&self) -> String {
|
||||
STORAGE.into()
|
||||
}
|
||||
|
||||
fn places(&self) -> Vec<(String, String)> {
|
||||
let mut v = vec![("Browser Storage".to_string(), STORAGE.to_string())];
|
||||
v.extend(state().open.iter().map(|n| (n.clone(), format!("{FOLDERS}/{n}"))));
|
||||
v
|
||||
}
|
||||
|
||||
fn list(&self, dir: &str, importable_only: bool) -> Result<Vec<Entry>, String> {
|
||||
let dir = if dir.len() > 1 { dir.trim_end_matches('/') } else { dir };
|
||||
if dir == STORAGE || dir.starts_with(&format!("{STORAGE}/")) {
|
||||
return storage_tree().list(dir, importable_only);
|
||||
}
|
||||
if dir == "/" {
|
||||
let mut t = VirtualTree::default();
|
||||
t.add_dir(STORAGE);
|
||||
t.add_dir(FOLDERS);
|
||||
return t.list("/", false);
|
||||
}
|
||||
let st = state();
|
||||
if dir == FOLDERS && st.open.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
st.folders.list(dir, importable_only).map_err(|e| {
|
||||
let name = dir.trim_start_matches(FOLDERS).trim_start_matches('/').split('/').next().unwrap_or("");
|
||||
if st.disconnected.iter().any(|d| d == name) { format!("{name}: click Reconnect {name} to allow access again") } else { e }
|
||||
})
|
||||
}
|
||||
|
||||
fn parent(&self, dir: &str) -> Option<String> {
|
||||
let d = dir.trim_end_matches('/');
|
||||
match d.rfind('/') {
|
||||
Some(0) if d.len() > 1 => Some("/".into()),
|
||||
Some(i) if i > 0 => Some(d[..i].to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch(&self, paths: &[String], params: &Value) -> bool {
|
||||
let missing: Vec<String> = paths.iter().filter(|p| p.starts_with(&format!("{FOLDERS}/")) && crate::files::get(p).is_none()).cloned().collect();
|
||||
if missing.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let params = params.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
for p in &missing {
|
||||
match wasm_bindgen_futures::JsFuture::from(read(p)).await {
|
||||
Ok(b) => crate::files::put(p, js_sys::Uint8Array::new(&b).to_vec().into()),
|
||||
Err(e) => {
|
||||
let msg = format!("{p}: {}", e.as_string().unwrap_or_else(|| "cannot read the file (reconnect its folder)".into()));
|
||||
crate::post(move |app| app.ui.status = msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::post(move |app| match app.session.execute("mediaBrowser.import", params) {
|
||||
Ok(r) => {
|
||||
if let Some(errs) = r["errors"].as_array().filter(|e| !e.is_empty()) {
|
||||
app.ui.status = errs.iter().filter_map(|e| e.as_str()).collect::<Vec<_>>().join("; ");
|
||||
}
|
||||
}
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
});
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
fn actions(&self) -> Vec<(String, String)> {
|
||||
let mut v = vec![];
|
||||
if can_pick_folder() {
|
||||
v.push(("openFolder".to_string(), "Open Folder…".to_string()));
|
||||
}
|
||||
v.push(("addFiles".into(), "Add Files…".into()));
|
||||
v.push(("uploadFolder".into(), "Upload Folder…".into()));
|
||||
for n in &state().disconnected {
|
||||
v.push((format!("reconnect:{n}"), format!("Reconnect {n}")));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn action(&self, id: &str) -> Result<Value, String> {
|
||||
match id {
|
||||
"openFolder" => {
|
||||
if !can_pick_folder() {
|
||||
return Err("this browser cannot open folders (use Add Files… or Upload Folder…)".into());
|
||||
}
|
||||
spawn(pick_folder(), "Open Folder", |app, v| {
|
||||
if let Some(n) = add_folder(&v) {
|
||||
go(app, &format!("{FOLDERS}/{n}"));
|
||||
}
|
||||
});
|
||||
Ok(json!({"pending": true}))
|
||||
}
|
||||
"addFiles" | "uploadFolder" => {
|
||||
let p = pick_files(id == "uploadFolder");
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Ok(list) = wasm_bindgen_futures::JsFuture::from(p).await else { return };
|
||||
let mut n = 0;
|
||||
for f in js_sys::Array::from(&list).iter() {
|
||||
let path = js_sys::Reflect::get(&f, &"path".into()).ok().and_then(|v| v.as_string());
|
||||
let bytes = js_sys::Reflect::get(&f, &"bytes".into()).ok().and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok());
|
||||
if let (Some(path), Some(bytes)) = (path, bytes) {
|
||||
crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
crate::post(move |app| {
|
||||
app.ui.status = format!("{n} file(s) added to Browser Storage");
|
||||
go(app, STORAGE);
|
||||
});
|
||||
});
|
||||
Ok(json!({"pending": true}))
|
||||
}
|
||||
_ => {
|
||||
if let Some(name) = id.strip_prefix("reconnect:") {
|
||||
spawn(reconnect(name), "Reconnect", |app, v| {
|
||||
if let Some(n) = add_folder(&v) {
|
||||
go(app, &format!("{FOLDERS}/{n}"));
|
||||
}
|
||||
});
|
||||
return Ok(json!({"pending": true}));
|
||||
}
|
||||
if let Some(name) = id.strip_prefix("forget:") {
|
||||
let _ = forget(name);
|
||||
let mut st = state();
|
||||
st.open.retain(|n| n != name);
|
||||
st.disconnected.retain(|n| n != name);
|
||||
st.folders.remove(&format!("{FOLDERS}/{name}"));
|
||||
return Ok(json!({"forgotten": name}));
|
||||
}
|
||||
Err(format!("unknown Media Browser action `{id}`"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Viewer frames rendered in Web Workers ([`effectcraft_engine::remote`]).
|
||||
//!
|
||||
//! **Page side** — [`WebFrames`], the frame cache's [`RemoteFrames`]: a few long-lived frame
|
||||
//! workers (`js/host.js` `frameWorkerStart`), each holding a replica of the project. Before a
|
||||
//! render, the worker gets the footage files it lacks and a sync (the whole project the first
|
||||
//! time, then diffs: [`Mirror`]); the frame comes back as premultiplied RGBA8 in a transferred
|
||||
//! buffer. One frame per worker at a time, so the page always decides what renders next (the
|
||||
//! viewer's frame before prefetch). A worker that fails is dropped; with none left, frames render
|
||||
//! on the page's thread again (`Frames::pump`).
|
||||
//!
|
||||
//! **Worker side** — [`worker_frame`]: the worker's [`FrameServer`] (footage from the worker's
|
||||
//! media pool, expressions) handles each message and posts its replies.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use effectcraft_engine::offload::footage_files;
|
||||
use effectcraft_engine::remote::{FrameMsg, FrameReply, FrameServer, Mirror};
|
||||
use effectcraft_ui_egui::frames::{RemoteDone, RemoteFrame, RemoteFrames, RemoteJob};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = frameWorkerStart)]
|
||||
fn frame_worker_start(base: &str, on_message: &js_sys::Function) -> u32;
|
||||
#[wasm_bindgen(js_name = frameWorkerPost)]
|
||||
fn frame_worker_post(index: u32, msg: &JsValue, transfer: &js_sys::Array);
|
||||
}
|
||||
|
||||
/// One frame worker as the page sees it.
|
||||
struct Fw {
|
||||
index: u32,
|
||||
mirror: Mirror,
|
||||
files: HashSet<String>,
|
||||
busy: Option<(u64, RemoteDone)>,
|
||||
alive: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
workers: Vec<Fw>,
|
||||
next_id: u64,
|
||||
rendered: u64,
|
||||
failed: u64,
|
||||
last_ms: f64,
|
||||
/// Bytes of project sync messages sent (diffs keep this small).
|
||||
sync_bytes: u64,
|
||||
syncs: u64,
|
||||
}
|
||||
|
||||
type OnMessage = Closure<dyn FnMut(u32, JsValue, JsValue, JsValue)>;
|
||||
|
||||
thread_local! {
|
||||
static STATE: RefCell<State> = RefCell::new(State::default());
|
||||
static HANDLER: RefCell<Option<OnMessage>> = const { RefCell::new(None) };
|
||||
/// The worker's frame server (worker side).
|
||||
static SERVER: RefCell<Option<FrameServer>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn obj(pairs: &[(&str, JsValue)]) -> JsValue {
|
||||
let o = js_sys::Object::new();
|
||||
for (k, v) in pairs {
|
||||
let _ = js_sys::Reflect::set(&o, &JsValue::from_str(k), v);
|
||||
}
|
||||
o.into()
|
||||
}
|
||||
|
||||
/// The page's frame workers (see the module docs).
|
||||
pub struct WebFrames;
|
||||
|
||||
impl WebFrames {
|
||||
/// Start `n` frame workers.
|
||||
pub fn start(n: usize) -> WebFrames {
|
||||
let on: OnMessage = Closure::new(|index: u32, json: JsValue, bytes: JsValue, error: JsValue| on_message(index, json, bytes, error));
|
||||
STATE.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
for _ in 0..n {
|
||||
let index = frame_worker_start(&crate::page_url(""), on.as_ref().unchecked_ref());
|
||||
s.workers.push(Fw { index, mirror: Mirror::default(), files: HashSet::new(), busy: None, alive: true });
|
||||
}
|
||||
});
|
||||
HANDLER.with(|h| *h.borrow_mut() = Some(on));
|
||||
WebFrames
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteFrames for WebFrames {
|
||||
fn slots(&self) -> usize {
|
||||
STATE.with(|s| s.borrow().workers.iter().filter(|w| w.alive).count())
|
||||
}
|
||||
|
||||
fn purge(&self) {
|
||||
purge();
|
||||
}
|
||||
|
||||
fn start(&self, job: RemoteJob, done: RemoteDone) {
|
||||
let r = STATE.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.next_id += 1;
|
||||
let id = s.next_id;
|
||||
let Some(k) = s.workers.iter().position(|w| w.alive && w.busy.is_none()) else { return Err((done, "no idle frame worker".to_string())) };
|
||||
// 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) {
|
||||
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 sync = w.mirror.sync(job.revision, &job.project);
|
||||
let index = w.index;
|
||||
let mut sent = 0;
|
||||
if let Some(m) = sync {
|
||||
let t = serde_json::to_string(&m).unwrap_or_default();
|
||||
sent = t.len() as u64;
|
||||
frame_worker_post(index, &obj(&[("type", "frame".into()), ("json", t.into())]), &js_sys::Array::new());
|
||||
}
|
||||
let req = FrameMsg::Render { id, revision: job.revision, comp: job.comp, time: job.t, opts: Box::new(job.opts) };
|
||||
frame_worker_post(
|
||||
index,
|
||||
&obj(&[("type", "frame".into()), ("json", serde_json::to_string(&req).unwrap_or_default().into())]),
|
||||
&js_sys::Array::new(),
|
||||
);
|
||||
s.workers[k].busy = Some((id, done));
|
||||
if sent > 0 {
|
||||
s.syncs += 1;
|
||||
s.sync_bytes += sent;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if let Err((done, e)) = r {
|
||||
done(Err(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A message from frame worker `index`: a JSON [`FrameReply`] (with the pixels in `bytes`), or
|
||||
/// `error` when the worker died.
|
||||
fn on_message(index: u32, json: JsValue, bytes: JsValue, error: JsValue) {
|
||||
let done = STATE.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
let st = &mut *s;
|
||||
let w = st.workers.iter_mut().find(|w| w.index == index)?;
|
||||
if let Some(e) = error.as_string() {
|
||||
log::warn!("frame worker {index}: {e}; frames render on the page's thread if none is left");
|
||||
w.alive = false;
|
||||
return w.busy.take().map(|(_, d)| (d, Err(e)));
|
||||
}
|
||||
let reply: FrameReply = serde_json::from_str(&json.as_string()?).ok()?;
|
||||
match reply {
|
||||
FrameReply::Frame { id, width, height, ms } => {
|
||||
let (_, d) = w.busy.take_if(|b| b.0 == id)?;
|
||||
let rgba = bytes.dyn_into::<js_sys::Uint8Array>().map(|b| b.to_vec()).unwrap_or_default();
|
||||
st.rendered += 1;
|
||||
st.last_ms = ms;
|
||||
Some((d, Ok(RemoteFrame { width, height, rgba, ms })))
|
||||
}
|
||||
FrameReply::Failed { id, error, .. } => {
|
||||
st.failed += 1;
|
||||
let (_, d) = w.busy.take_if(|b| b.0 == id)?;
|
||||
Some((d, Err(error)))
|
||||
}
|
||||
FrameReply::Resync { error } => {
|
||||
log::info!("frame worker {index}: {error}: sending the whole project again");
|
||||
w.mirror.reset();
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some((d, r)) = done {
|
||||
d(r);
|
||||
}
|
||||
crate::repaint();
|
||||
}
|
||||
|
||||
/// Relay Roto Brush segmentations (from a propagation worker) to the frame workers, so their
|
||||
/// renders use them instead of computing them again.
|
||||
pub fn relay_segs(segs: &[effectcraft_engine::offload::SegData]) {
|
||||
if segs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let t = serde_json::to_string(&FrameMsg::Segs { segs: segs.to_vec() }).unwrap_or_default();
|
||||
STATE.with(|s| {
|
||||
for w in s.borrow().workers.iter().filter(|w| w.alive) {
|
||||
frame_worker_post(w.index, &obj(&[("type", "frame".into()), ("json", t.as_str().into())]), &js_sys::Array::new());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask every frame worker to drop its cached layer buffers (Edit ▸ Purge).
|
||||
pub fn purge() {
|
||||
let t = serde_json::to_string(&FrameMsg::Purge).unwrap_or_default();
|
||||
STATE.with(|s| {
|
||||
for w in s.borrow().workers.iter().filter(|w| w.alive) {
|
||||
frame_worker_post(w.index, &obj(&[("type", "frame".into()), ("json", t.as_str().into())]), &js_sys::Array::new());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `effectcraft.info().frameWorkers`.
|
||||
pub fn stats() -> Value {
|
||||
STATE.with(|s| {
|
||||
let s = s.borrow();
|
||||
json!({
|
||||
"workers": s.workers.len(),
|
||||
"alive": s.workers.iter().filter(|w| w.alive).count(),
|
||||
"busy": s.workers.iter().filter(|w| w.busy.is_some()).count(),
|
||||
"rendered": s.rendered,
|
||||
"failed": s.failed,
|
||||
"lastMs": s.last_ms,
|
||||
"syncs": s.syncs,
|
||||
"syncBytes": s.sync_bytes,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- worker side
|
||||
|
||||
/// Handle one frame message (a JSON [`FrameMsg`]) in a frame worker; replies are posted with
|
||||
/// their pixels transferred.
|
||||
#[wasm_bindgen(js_name = workerFrame)]
|
||||
pub fn worker_frame(json: String) {
|
||||
let msg: FrameMsg = match serde_json::from_str(&json) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log::warn!("frame worker: bad message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let replies = SERVER.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let server = c.get_or_insert_with(|| {
|
||||
let pool = crate::worker::pool();
|
||||
FrameServer::new(pool, Some(std::sync::Arc::new(effectcraft_expr::Expressions)))
|
||||
});
|
||||
server.handle(msg)
|
||||
});
|
||||
let scope: web_sys::DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
|
||||
for (r, px) in replies {
|
||||
let t = serde_json::to_string(&r).unwrap_or_default();
|
||||
let res = match px {
|
||||
Some(px) => {
|
||||
let buf = js_sys::Uint8Array::from(&px[..]).buffer();
|
||||
scope.post_message_with_transfer(
|
||||
&obj(&[("type", "frameReply".into()), ("json", t.into()), ("bytes", buf.clone().into())]),
|
||||
&js_sys::Array::of1(&buf),
|
||||
)
|
||||
}
|
||||
None => scope.post_message(&obj(&[("type", "frameReply".into()), ("json", t.into())])),
|
||||
};
|
||||
if let Err(e) = res {
|
||||
log::warn!("frame worker: postMessage: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@
|
||||
//! | file system (`FsServices`, media reads, export writes) | a virtual file table ([`files`]) persisted to the Origin Private File System / IndexedDB ([`persist`], [`store`]) |
|
||||
//! | config directory (settings, shortcuts, recent projects, auto-saves) | the same store ([`store::WebConfig`]) |
|
||||
//! | rfd file dialogs | `<input type=file>` pickers; drop files on the page |
|
||||
//! | Media Browser on the file system | browser storage and folders opened with the File System Access API ([`browse`]) |
|
||||
//! | written files (Save, Render Queue) | browser downloads (several render files as one `.zip`) |
|
||||
//! | frame render threads | `Frames::pump` on the UI thread between egui frames |
|
||||
//! | frame render threads | frame workers, each with a project replica fed by diffs ([`frames`]); `Frames::pump` on the UI thread without workers |
|
||||
//! | Render Queue / analysis threads | Web Workers running their own engine instance ([`worker`]) |
|
||||
//! | cpal audio output | Web Audio: an AudioWorklet fed from the preview mixdown ([`audio`]) |
|
||||
//! | TCP control channel / MCP | `window.effectcraft` JavaScript API ([`api`]) |
|
||||
@@ -26,8 +27,12 @@ mod app;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod audio;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod browse;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod frames;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod persist;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod worker;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
//! `workerRun`), and turns the worker's messages back into [`WorkerReply`]s in the job's
|
||||
//! [`Inbox`]. Rendered files come back as their bytes and download like any render.
|
||||
//!
|
||||
//! Viewer frames use separate, long-lived workers ([`crate::frames`]); Roto Brush
|
||||
//! segmentations a propagation job sends back are relayed to them.
|
||||
//!
|
||||
//! **Worker side** — `web/worker.js` instantiates the same wasm module (the page's compiled
|
||||
//! `WebAssembly.Module`, so nothing is compiled twice) and calls [`worker_init`], then
|
||||
//! [`worker_file`] per footage file and [`worker_job`] per job: a plain engine session (footage
|
||||
@@ -60,6 +63,9 @@ impl Offload for WorkerOffload {
|
||||
let Some(t) = json.as_string() else { return };
|
||||
match serde_json::from_str::<WorkerReply>(&t) {
|
||||
Ok(r) => {
|
||||
if let WorkerReply::Segs { segs } = &r {
|
||||
crate::frames::relay_segs(segs);
|
||||
}
|
||||
let done = matches!(r, WorkerReply::Done);
|
||||
inbox.push(r);
|
||||
if done {
|
||||
@@ -146,6 +152,11 @@ pub fn worker_init() {
|
||||
SESSION.with(|c| *c.borrow_mut() = Some(s));
|
||||
}
|
||||
|
||||
/// The worker's media pool (footage files sent by the page).
|
||||
pub fn pool() -> Arc<effectcraft_media::MediaPool> {
|
||||
POOL.with(|p| p.borrow().clone()).unwrap_or_else(|| Arc::new(effectcraft_media::MediaPool::new()))
|
||||
}
|
||||
|
||||
/// A footage file for the jobs that follow.
|
||||
#[wasm_bindgen(js_name = workerFile)]
|
||||
pub fn worker_file(path: String, bytes: js_sys::Uint8Array) {
|
||||
|
||||
@@ -167,14 +167,48 @@ try {
|
||||
const f = await js("effectcraft.renderFrame({max_side: 480}).then(r => ({w: r.width, h: r.height, pngBytes: atob(r.png).length}))");
|
||||
report.steps.renderFrame = f;
|
||||
|
||||
// Render Queue: a short, small GIF → download.
|
||||
// Viewer frames render in frame workers (project replicas fed by diffs): with the CPU
|
||||
// renderer, scrubbing (and an edit, which travels as a diff) keeps the page's event loop free.
|
||||
await js(`effectcraft.execute("render.backend", {backend: "cpu"})`);
|
||||
report.steps.frameWorkers = await js(`(async () => {
|
||||
const before = effectcraft.info().frameWorkers;
|
||||
let maxGapMs = 0, last = performance.now();
|
||||
const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10);
|
||||
await effectcraft.execute("layer.newSolid", {name: "Frame Worker Solid", color: "#3366cc", width: 120, height: 80});
|
||||
for (let k = 0; k < 12; k++) {
|
||||
await effectcraft.execute("time.set", {time: k / 4});
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
const t0 = performance.now();
|
||||
while (effectcraft.info().frameWorkers.rendered < before.rendered + 3 && performance.now() - t0 < 120000) await new Promise((r) => setTimeout(r, 100));
|
||||
clearInterval(timer);
|
||||
await effectcraft.execute("edit.undo", {});
|
||||
return {before, after: effectcraft.info().frameWorkers, maxGapMs};
|
||||
})()`);
|
||||
const fwk = report.steps.frameWorkers;
|
||||
await js(`effectcraft.execute("render.backend", {backend: "gpu"})`);
|
||||
check(fwk.after.alive > 0, `frame workers alive: ${JSON.stringify(fwk.after)}`);
|
||||
check(fwk.after.rendered >= fwk.before.rendered + 3, `frames rendered in workers: ${JSON.stringify(fwk)}`);
|
||||
check(fwk.after.syncs >= 2, `project synced as diffs: ${JSON.stringify(fwk.after)}`);
|
||||
check(fwk.maxGapMs < 400, `event loop blocked ${fwk.maxGapMs} ms while scrubbing`);
|
||||
|
||||
// Render Queue: a short, small GIF → download. `renderQueue.render` waits by default; the
|
||||
// render still runs in a worker and the reply comes when it ends (the page keeps running).
|
||||
const rqT = Date.now();
|
||||
// (`ui.menu.invoke`: `engine.execute`'s strict parameter check does not know the spread
|
||||
// Render Settings / Output Module keys)
|
||||
await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", output: "smoke_[compName].gif", resolution: 0.25, timeSpan: "custom", start: 0, end: 1}})`);
|
||||
await js(`effectcraft.execute("renderQueue.render", {})`);
|
||||
const waited = await js(`(async () => {
|
||||
let maxGapMs = 0, last = performance.now();
|
||||
const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10);
|
||||
const r = await effectcraft.execute("renderQueue.render", {});
|
||||
clearInterval(timer);
|
||||
return {rendering: r.rendering, status: r.items.map((i) => i.status), maxGapMs};
|
||||
})()`);
|
||||
const gif = await waitDownload((n) => n.endsWith(".gif"));
|
||||
report.steps.gifExport = { ...gif, ms: Date.now() - rqT };
|
||||
report.steps.gifExport = { ...gif, ms: Date.now() - rqT, waited };
|
||||
check(!waited.rendering && waited.status.every((s) => s === "Done"), `wait:true replies when done: ${JSON.stringify(waited)}`);
|
||||
check(waited.maxGapMs < 400, `event loop blocked ${waited.maxGapMs} ms during a waiting render`);
|
||||
await idle();
|
||||
|
||||
// An image sequence arrives as one .zip.
|
||||
@@ -231,6 +265,13 @@ try {
|
||||
return effectcraft.addFile(new File([b], "tone.wav"));
|
||||
})()`);
|
||||
await until(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => true, () => false)`, 20000, 300);
|
||||
// Media Browser: browser storage (where tone.wav now is) and its actions.
|
||||
const mb = await js(`effectcraft.execute("mediaBrowser.go", {})`);
|
||||
report.steps.mediaBrowser = { path: mb.path, entries: mb.entries.map((e) => e.name), places: mb.places, actions: mb.actions.map((a) => a.id) };
|
||||
check(mb.path === "/Browser Storage" && mb.entries.some((e) => e.name === "tone.wav" && e.kind === "audio"), `Media Browser: ${JSON.stringify(report.steps.mediaBrowser)}`);
|
||||
check(mb.actions.some((a) => a.id === "addFiles"), "Media Browser: Add Files…");
|
||||
const mbImport = await js(`effectcraft.execute("mediaBrowser.import", {paths: [${JSON.stringify(mb.entries.find((e) => e.name === "tone.wav")?.path ?? "/tone.wav")}]})`);
|
||||
check(!mbImport.pending && mbImport.items && mbImport.items.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`);
|
||||
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
|
||||
await js(`effectcraft.execute("time.set", {time: 0})`);
|
||||
await js(`effectcraft.request("ui.playback", {action: "play"})`);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// An EffectCraft engine instance in a Web Worker: renders the Render Queue and runs analyses
|
||||
// off the page's thread (src/worker.rs, crates/engine/src/offload.rs). The page sends the
|
||||
// compiled module, then footage files and jobs; the worker replies with JSON messages and
|
||||
// rendered files.
|
||||
// An EffectCraft engine instance in a Web Worker (src/worker.rs, src/frames.rs,
|
||||
// crates/engine/src/offload.rs and remote.rs). The page sends the compiled module, then:
|
||||
// - job workers: footage files and jobs (Render Queue, analyses, Roto Brush); the worker replies
|
||||
// with JSON messages and rendered files;
|
||||
// - frame workers: footage files and frame messages (project syncs as diffs, render requests,
|
||||
// Roto Brush segmentations); the worker replies with frames (pixels transferred).
|
||||
import init, * as ec from "./effectcraft_web.js";
|
||||
|
||||
let ready = null;
|
||||
@@ -16,4 +18,5 @@ self.onmessage = async (e) => {
|
||||
await ready;
|
||||
if (m.type === "file") ec.workerFile(m.path, m.bytes);
|
||||
else if (m.type === "job") ec.workerJob(m.json);
|
||||
else if (m.type === "frame") ec.workerFrame(m.json);
|
||||
};
|
||||
|
||||
@@ -269,7 +269,7 @@ fn parse_points(s: &str) -> Vec<(f32, f32)> {
|
||||
/// around (x = 0 and x = 1 are the same hue); the others are flat beyond their end points.
|
||||
/// Tabulated as offsets `y − 0.5`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OffsetCurve {
|
||||
pub struct OffsetCurve {
|
||||
lut: Vec<f32>,
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ impl OffsetCurve {
|
||||
const N: usize = 512;
|
||||
|
||||
/// `None` for an empty or flat-neutral curve.
|
||||
pub(crate) fn parse(s: &str, periodic: bool) -> Option<OffsetCurve> {
|
||||
pub fn parse(s: &str, periodic: bool) -> Option<OffsetCurve> {
|
||||
let pts = parse_points(s);
|
||||
if pts.is_empty() || pts.iter().all(|p| (p.1 - 0.5).abs() < 1e-6) {
|
||||
return None;
|
||||
@@ -327,7 +327,7 @@ impl OffsetCurve {
|
||||
}
|
||||
|
||||
/// Offset from neutral at `x` (clamped to 0..1).
|
||||
pub(crate) fn at(&self, x: f32) -> f32 {
|
||||
pub fn at(&self, x: f32) -> f32 {
|
||||
let f = x.clamp(0.0, 1.0) * Self::N as f32;
|
||||
let i = (f as usize).min(Self::N - 1);
|
||||
let t = f - i as f32;
|
||||
|
||||
@@ -66,11 +66,13 @@ use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use card3d::{CompLight, CompScene};
|
||||
pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette};
|
||||
pub use color_fx::{
|
||||
HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity,
|
||||
levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip,
|
||||
};
|
||||
pub use color2::{Curve, auto_correct_settings, equalize_tables, luma_clip_points, shadow_highlight_amounts};
|
||||
pub use color3::OffsetCurve;
|
||||
pub use distort::transform_shutter;
|
||||
pub use distort2::{MAGNIFY_MODES, parse_mesh};
|
||||
pub use distort4::liquify_mesh;
|
||||
@@ -78,7 +80,7 @@ use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::Ids;
|
||||
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
|
||||
pub use effectcraft_raster::{AuxChannels, Image};
|
||||
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_operation};
|
||||
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_ab_t, glow_operation};
|
||||
pub use sim::particle_state;
|
||||
pub use sim3::playground_state;
|
||||
pub use stylize2::strobe_on;
|
||||
|
||||
@@ -103,7 +103,7 @@ fn unsharp(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
/// Position (0 = Color A, 1 = Color B) in Glow's A & B gradient for brightness `l`: `loops`
|
||||
/// cycles starting at `phase` (revolutions), shaped by Color Looping (0 Sawtooth A>B,
|
||||
/// 1 Sawtooth B>A, 2 Triangle A>B>A, 3 Triangle B>A>B) and biased so `mid` maps to the middle.
|
||||
pub(crate) fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32 {
|
||||
pub fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) -> f32 {
|
||||
let u = l.clamp(0.0, 1.0) * loops.max(0.0) + phase;
|
||||
let mut t = u - u.floor();
|
||||
if u > 0.0 && t <= 0.0 {
|
||||
|
||||
@@ -29,38 +29,61 @@ fn jobs_wait(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
// ---------------------------------------------------------------- Media Browser
|
||||
|
||||
fn browser_dir(s: &Session, p: &Value) -> String {
|
||||
str_p(p, "path").map(str::to_string).or_else(|| s.state.media_browser.folder.clone()).unwrap_or_else(mb::home)
|
||||
fn browser(s: &Session) -> Result<std::sync::Arc<dyn mb::Browser>> {
|
||||
s.media_browser().ok_or_else(|| EngineError::Other("the Media Browser is not available in this build".into()))
|
||||
}
|
||||
|
||||
fn browser_dir(s: &Session, b: &dyn mb::Browser, p: &Value) -> String {
|
||||
str_p(p, "path").map(str::to_string).or_else(|| s.state.media_browser.folder.clone()).unwrap_or_else(|| b.home())
|
||||
}
|
||||
|
||||
fn browser_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dir = browser_dir(s, p);
|
||||
let b = browser(s)?;
|
||||
let dir = browser_dir(s, b.as_ref(), p);
|
||||
let only = b_p(p, "importableOnly").unwrap_or(s.state.media_browser.importable_only);
|
||||
let entries = mb::list(&dir, only).map_err(EngineError::Other)?;
|
||||
Ok(json!({"path": dir, "parent": mb::parent(&dir), "entries": entries, "favorites": s.state.media_browser.favorites}))
|
||||
let entries = b.list(&dir, only).map_err(EngineError::Other)?;
|
||||
let places: Vec<Value> = b.places().into_iter().map(|(name, path)| json!({"name": name, "path": path})).collect();
|
||||
let actions: Vec<Value> = b.actions().into_iter().map(|(id, label)| json!({"id": id, "label": label})).collect();
|
||||
Ok(json!({
|
||||
"path": dir,
|
||||
"parent": b.parent(&dir),
|
||||
"entries": entries,
|
||||
"favorites": s.state.media_browser.favorites,
|
||||
"places": places,
|
||||
"actions": actions,
|
||||
}))
|
||||
}
|
||||
|
||||
fn browser_enabled(_: &Session) -> std::result::Result<(), String> {
|
||||
if mb::available() { Ok(()) } else { Err("the Media Browser needs the desktop app".into()) }
|
||||
fn browser_enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.media_browser().is_some() { Ok(()) } else { Err("the Media Browser is not available in this build".into()) }
|
||||
}
|
||||
|
||||
fn browser_go(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = browser(s)?;
|
||||
let dir = match str_p(p, "path") {
|
||||
Some("..") => s.state.media_browser.folder.clone().and_then(|d| mb::parent(&d)).unwrap_or_else(mb::home),
|
||||
Some("..") => s.state.media_browser.folder.clone().and_then(|d| b.parent(&d)).unwrap_or_else(|| b.home()),
|
||||
Some(d) => d.to_string(),
|
||||
None => mb::home(),
|
||||
None => b.home(),
|
||||
};
|
||||
if let Some(b) = b_p(p, "importableOnly") {
|
||||
s.state.media_browser.importable_only = b;
|
||||
if let Some(v) = b_p(p, "importableOnly") {
|
||||
s.state.media_browser.importable_only = v;
|
||||
}
|
||||
// Validate before switching.
|
||||
mb::list(&dir, false).map_err(EngineError::Other)?;
|
||||
b.list(&dir, false).map_err(EngineError::Other)?;
|
||||
s.state.media_browser.folder = Some(dir.clone());
|
||||
browser_list(s, &json!({"path": dir}))
|
||||
}
|
||||
|
||||
/// A browser-specific toolbar action (web: Open Folder…, Add Files…).
|
||||
fn browser_action(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = browser(s)?;
|
||||
let id = str_p(p, "action").ok_or_else(|| bad("mediaBrowser.action", "missing `action`"))?;
|
||||
b.action(id).map_err(|e| bad("mediaBrowser.action", e))
|
||||
}
|
||||
|
||||
fn browser_favorite(s: &mut Session, p: &Value, add: bool) -> Result<Value> {
|
||||
let dir = browser_dir(s, p);
|
||||
let b = browser(s)?;
|
||||
let dir = browser_dir(s, b.as_ref(), p);
|
||||
let favs = &mut s.state.media_browser.favorites;
|
||||
favs.retain(|f| f != &dir);
|
||||
if add {
|
||||
@@ -71,6 +94,16 @@ fn browser_favorite(s: &mut Session, p: &Value, add: bool) -> Result<Value> {
|
||||
|
||||
fn browser_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let paths = p.get("paths").or(p.get("path")).cloned().ok_or_else(|| bad("mediaBrowser.import", "missing `paths`"))?;
|
||||
// Files the browser still has to read (the web app's opened folders): imported when they
|
||||
// arrive.
|
||||
let list: Vec<String> = match &paths {
|
||||
Value::String(one) => vec![one.clone()],
|
||||
Value::Array(a) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
if !browser(s)?.fetch(&list, p) {
|
||||
return Ok(json!({"pending": true, "paths": list}));
|
||||
}
|
||||
let r = s.execute("file.import", json!({"paths": paths}))?;
|
||||
// Add to the active comp (drag into the timeline).
|
||||
if b_p(p, "addToComp").unwrap_or(false)
|
||||
@@ -201,6 +234,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("mediaBrowser.addFavorite", "Add to Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, true)),
|
||||
cmd!("mediaBrowser.removeFavorite", "Remove from Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, false)),
|
||||
cmd!("mediaBrowser.import", "Import", [], None, "{paths, addToComp?}", browser_enabled, browser_import),
|
||||
cmd!(
|
||||
"mediaBrowser.action",
|
||||
"Media Browser Action",
|
||||
[],
|
||||
None,
|
||||
"{action}: an action from mediaBrowser.list's `actions` (web: openFolder, addFiles, uploadFolder)",
|
||||
browser_enabled,
|
||||
browser_action
|
||||
),
|
||||
query!("mediaBrowser.fileInfo", "File Info", "{path}", file_info),
|
||||
query!("item.metadata", "Metadata", "{item?: id|name}", metadata),
|
||||
cmd!("project.setProjectComment", "Project Comment", [], None, "{comment}", always, project_comment),
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod offload;
|
||||
pub mod prefs;
|
||||
pub mod preview;
|
||||
pub mod psd_import;
|
||||
pub mod remote;
|
||||
pub mod render_queue;
|
||||
pub mod roto;
|
||||
pub mod scriptui;
|
||||
@@ -353,6 +354,11 @@ pub struct Session {
|
||||
pub offload: Option<Arc<dyn offload::Offload>>,
|
||||
/// Analyses running in the [`Session::offload`] worker.
|
||||
pub offloaded: Vec<offload::OffloadedJob>,
|
||||
/// What the Media Browser browses (`None`: the local file system where there is one).
|
||||
pub browser: Option<Arc<dyn media_browser::Browser>>,
|
||||
/// Offloaded jobs that ended, newest last (kind, error): how a caller waiting for one
|
||||
/// (`wait: true` over the control channel in the browser) learns its outcome.
|
||||
pub offload_log: Vec<(offload::JobKind, Option<String>)>,
|
||||
/// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`].
|
||||
pub tasks: Vec<jobs::Task>,
|
||||
/// Finished tasks, newest last (Progress panel, `jobs.list`).
|
||||
@@ -425,6 +431,8 @@ impl Default for Session {
|
||||
applied_nested_switches: None,
|
||||
offload: None,
|
||||
offloaded: vec![],
|
||||
offload_log: vec![],
|
||||
browser: None,
|
||||
tasks: vec![],
|
||||
job_log: vec![],
|
||||
next_task_id: 1,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
//! Media Browser (Window ▸ Media Browser): browse the local file system for footage, keep
|
||||
//! favourite folders, read file metadata, and import (`file.import`). Desktop only: the web build
|
||||
//! has no file system to browse ([`available`] is false there).
|
||||
//! Media Browser (Window ▸ Media Browser): browse for footage, keep favourite folders, read file
|
||||
//! metadata, and import (`file.import`).
|
||||
//!
|
||||
//! What it browses is a [`Browser`]: the local file system on the desktop ([`FsBrowser`]); in the
|
||||
//! web app, a virtual tree ([`VirtualTree`]) of the browser's storage and of folders the user
|
||||
//! opened through the File System Access API, whose files are read only when imported
|
||||
//! ([`Browser::fetch`]).
|
||||
//!
|
||||
//! Also the metadata the Metadata panel shows for project items and files (codec, size, rate,
|
||||
//! duration, colour profile, dates).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -25,11 +31,153 @@ pub struct BrowserState {
|
||||
pub importable_only: bool,
|
||||
}
|
||||
|
||||
/// Whether this build can browse files.
|
||||
/// Whether this build can browse the local file system ([`FsBrowser`]).
|
||||
pub fn available() -> bool {
|
||||
!cfg!(target_arch = "wasm32")
|
||||
}
|
||||
|
||||
/// Where the Media Browser browses.
|
||||
pub trait Browser: Send + Sync {
|
||||
/// The folder shown first.
|
||||
fn home(&self) -> String;
|
||||
/// Places listed above the favourites: (name, folder). The default is Home.
|
||||
fn places(&self) -> Vec<(String, String)> {
|
||||
vec![("Home".into(), self.home())]
|
||||
}
|
||||
/// A folder's entries: folders first, then files, by name.
|
||||
fn list(&self, dir: &str, importable_only: bool) -> Result<Vec<Entry>, String>;
|
||||
/// The folder above `dir`.
|
||||
fn parent(&self, dir: &str) -> Option<String> {
|
||||
parent(dir)
|
||||
}
|
||||
/// Make `paths` readable by the importer. `false`: they are being fetched, and the host runs
|
||||
/// `mediaBrowser.import` with `params` again once they are (the web app reads opened folders'
|
||||
/// files on demand).
|
||||
fn fetch(&self, paths: &[String], params: &Value) -> bool {
|
||||
let _ = (paths, params);
|
||||
true
|
||||
}
|
||||
/// Extra toolbar actions: (id, label), run by `mediaBrowser.action`.
|
||||
fn actions(&self) -> Vec<(String, String)> {
|
||||
vec![]
|
||||
}
|
||||
fn action(&self, id: &str) -> Result<Value, String> {
|
||||
Err(format!("unknown Media Browser action `{id}`"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The local file system (desktop).
|
||||
pub struct FsBrowser;
|
||||
|
||||
impl Browser for FsBrowser {
|
||||
fn home(&self) -> String {
|
||||
home()
|
||||
}
|
||||
fn list(&self, dir: &str, importable_only: bool) -> Result<Vec<Entry>, String> {
|
||||
list(dir, importable_only)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// What the Media Browser browses: [`crate::Session::browser`], else the local file system
|
||||
/// where this build has one.
|
||||
pub fn media_browser(&self) -> Option<std::sync::Arc<dyn Browser>> {
|
||||
self.browser.clone().or_else(|| available().then(|| std::sync::Arc::new(FsBrowser) as std::sync::Arc<dyn Browser>))
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of a [`VirtualTree`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct VNode {
|
||||
/// What importing it reads (`None`: a folder).
|
||||
import: Option<String>,
|
||||
size: u64,
|
||||
modified: Option<u64>,
|
||||
}
|
||||
|
||||
/// A browsable tree of virtual paths (`/Folder/sub/file.mov`), each file standing for an
|
||||
/// importable path (the web app's file table, or a file of an opened folder).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct VirtualTree {
|
||||
nodes: BTreeMap<String, VNode>,
|
||||
}
|
||||
|
||||
fn vparent(path: &str) -> Option<&str> {
|
||||
if path == "/" || path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let p = path.trim_end_matches('/');
|
||||
match p.rfind('/') {
|
||||
Some(0) => Some("/"),
|
||||
Some(i) => Some(&p[..i]),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualTree {
|
||||
/// Add a folder (and the folders above it).
|
||||
pub fn add_dir(&mut self, path: &str) {
|
||||
let mut cur = Some(path.trim_end_matches('/'));
|
||||
while let Some(p) = cur.filter(|p| !p.is_empty() && *p != "/") {
|
||||
self.nodes.entry(p.to_string()).or_insert(VNode { import: None, size: 0, modified: None });
|
||||
cur = vparent(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a file shown at `path` that imports `import`.
|
||||
pub fn add_file(&mut self, path: &str, import: &str, size: u64, modified: Option<u64>) {
|
||||
if let Some(p) = vparent(path) {
|
||||
self.add_dir(p);
|
||||
}
|
||||
self.nodes.insert(path.to_string(), VNode { import: Some(import.to_string()), size, modified });
|
||||
}
|
||||
|
||||
/// Remove `path` and everything under it.
|
||||
pub fn remove(&mut self, path: &str) {
|
||||
let pre = format!("{}/", path.trim_end_matches('/'));
|
||||
self.nodes.retain(|k, _| k != path && !k.starts_with(&pre));
|
||||
}
|
||||
|
||||
pub fn is_dir(&self, path: &str) -> bool {
|
||||
path == "/" || self.nodes.get(path.trim_end_matches('/')).is_some_and(|n| n.import.is_none())
|
||||
}
|
||||
|
||||
/// What a file shown at `path` imports.
|
||||
pub fn import_path(&self, path: &str) -> Option<&str> {
|
||||
self.nodes.get(path).and_then(|n| n.import.as_deref())
|
||||
}
|
||||
|
||||
pub fn list(&self, dir: &str, importable_only: bool) -> Result<Vec<Entry>, String> {
|
||||
let dir = if dir.len() > 1 { dir.trim_end_matches('/') } else { dir };
|
||||
if !self.is_dir(dir) {
|
||||
return Err(format!("{dir}: no such folder"));
|
||||
}
|
||||
let mut out: Vec<Entry> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|(k, _)| vparent(k) == Some(dir))
|
||||
.filter_map(|(k, n)| {
|
||||
let name = k.rsplit('/').next().unwrap_or(k).to_string();
|
||||
let kind = n.import.as_deref().and_then(kind_of);
|
||||
if importable_only && n.import.is_some() && kind.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(Entry {
|
||||
name,
|
||||
// Folders browse by their virtual path; files import their own path.
|
||||
path: n.import.clone().unwrap_or_else(|| k.clone()),
|
||||
is_dir: n.import.is_none(),
|
||||
size: n.size,
|
||||
modified: n.modified,
|
||||
kind,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// One directory entry.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -261,6 +409,32 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_tree_lists_like_a_folder() {
|
||||
let mut t = VirtualTree::default();
|
||||
t.add_file("/Browser Storage/clip.mov", "/clip.mov", 10, Some(5));
|
||||
t.add_file("/Folders/Shoot/B/b.png", "/Folders/Shoot/B/b.png", 3, None);
|
||||
t.add_file("/Folders/Shoot/a.wav", "/Folders/Shoot/a.wav", 4, None);
|
||||
t.add_file("/Folders/Shoot/notes.txt", "/Folders/Shoot/notes.txt", 1, None);
|
||||
t.add_dir("/Folders/Empty");
|
||||
let names = |t: &VirtualTree, d: &str, only: bool| t.list(d, only).unwrap().into_iter().map(|e| e.name).collect::<Vec<_>>();
|
||||
assert_eq!(names(&t, "/", false), ["Browser Storage", "Folders"]);
|
||||
assert_eq!(names(&t, "/Folders", false), ["Empty", "Shoot"]);
|
||||
assert_eq!(names(&t, "/Folders/Shoot", false), ["B", "a.wav", "notes.txt"]);
|
||||
assert_eq!(names(&t, "/Folders/Shoot/", true), ["B", "a.wav"]);
|
||||
let e = &t.list("/Browser Storage", false).unwrap()[0];
|
||||
assert_eq!((e.path.as_str(), e.size, e.modified, e.kind, e.is_dir), ("/clip.mov", 10, Some(5), Some("video"), false));
|
||||
let d = &t.list("/Folders", false).unwrap()[1];
|
||||
assert_eq!((d.path.as_str(), d.is_dir), ("/Folders/Shoot", true));
|
||||
assert_eq!(t.import_path("/Browser Storage/clip.mov"), Some("/clip.mov"));
|
||||
assert!(t.list("/Folders/Shoot/a.wav", false).is_err(), "a file is not a folder");
|
||||
assert!(t.list("/Nope", false).is_err());
|
||||
t.remove("/Folders/Shoot");
|
||||
assert_eq!(names(&t, "/Folders", false), ["Empty"]);
|
||||
assert_eq!(vparent("/a"), Some("/"));
|
||||
assert_eq!(vparent("/"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_dates() {
|
||||
assert_eq!(iso_date(0), "1970-01-01T00:00:00Z");
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
//! | Track Motion / Stabilize Motion | the tracker analysis | the tracker group |
|
||||
//! | Mask tracking | the mask track | the mask group |
|
||||
//! | Roto Brush Freeze | the freeze | the effect group |
|
||||
//! | Roto Brush propagation | the propagation | segmentations ([`WorkerReply::Segs`], streamed while it runs) go into the page's segmentation cache |
|
||||
//! | 3D Camera Tracker analysis | [`Session::start_camera`] | the effect's property group |
|
||||
//!
|
||||
//! Roto Brush propagation fills the session's segmentation cache rather than the project, so it
|
||||
//! keeps running on the UI thread.
|
||||
//! Viewer frames go to a separate, long-lived frame worker fed with project diffs
|
||||
//! ([`crate::remote`]).
|
||||
//!
|
||||
//! Cancelling terminates the worker: what an analysis tracked so far is dropped and the Render
|
||||
//! Queue item being rendered becomes "User Stopped".
|
||||
@@ -76,6 +77,13 @@ pub enum WorkerJob {
|
||||
layer: LayerId,
|
||||
effect: Uid,
|
||||
},
|
||||
/// Roto Brush propagation: the segmentations come back as [`WorkerReply::Segs`].
|
||||
RotoPropagate {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
effect: Uid,
|
||||
direction: crate::roto::Direction,
|
||||
},
|
||||
Track {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
@@ -103,6 +111,7 @@ impl WorkerJob {
|
||||
WorkerJob::Warp { .. } => JobKind::Warp,
|
||||
WorkerJob::Camera { .. } => JobKind::Camera,
|
||||
WorkerJob::RotoFreeze { .. } => JobKind::RotoFreeze,
|
||||
WorkerJob::RotoPropagate { .. } => JobKind::RotoPropagate,
|
||||
WorkerJob::Track { .. } => JobKind::Track,
|
||||
WorkerJob::MaskTrack { .. } => JobKind::MaskTrack,
|
||||
}
|
||||
@@ -112,9 +121,10 @@ impl WorkerJob {
|
||||
pub fn target(&self) -> Option<(ItemId, LayerId, Uid)> {
|
||||
match *self {
|
||||
WorkerJob::Render { .. } => None,
|
||||
WorkerJob::Warp { comp, layer, effect } | WorkerJob::Camera { comp, layer, effect } | WorkerJob::RotoFreeze { comp, layer, effect } => {
|
||||
Some((comp, layer, effect))
|
||||
}
|
||||
WorkerJob::Warp { comp, layer, effect }
|
||||
| WorkerJob::Camera { comp, layer, effect }
|
||||
| WorkerJob::RotoFreeze { comp, layer, effect }
|
||||
| WorkerJob::RotoPropagate { comp, layer, effect, .. } => Some((comp, layer, effect)),
|
||||
WorkerJob::Track { comp, layer, tracker, .. } => Some((comp, layer, tracker)),
|
||||
WorkerJob::MaskTrack { comp, layer, mask, .. } => Some((comp, layer, mask)),
|
||||
}
|
||||
@@ -128,6 +138,7 @@ pub enum JobKind {
|
||||
Warp,
|
||||
Camera,
|
||||
RotoFreeze,
|
||||
RotoPropagate,
|
||||
Track,
|
||||
MaskTrack,
|
||||
}
|
||||
@@ -140,6 +151,7 @@ impl JobKind {
|
||||
JobKind::Warp => "Warp Stabilizer Analysis",
|
||||
JobKind::Camera => "3D Camera Tracker Analysis",
|
||||
JobKind::RotoFreeze => "Freeze",
|
||||
JobKind::RotoPropagate => "Roto Brush Propagation",
|
||||
JobKind::Track => "Analyze Track",
|
||||
JobKind::MaskTrack => "Track Mask",
|
||||
}
|
||||
@@ -170,6 +182,10 @@ pub enum WorkerReply {
|
||||
group: Box<PropGroup>,
|
||||
message: String,
|
||||
},
|
||||
/// Roto Brush segmentations computed so far (propagation), not sent before.
|
||||
Segs {
|
||||
segs: Vec<SegData>,
|
||||
},
|
||||
Failed {
|
||||
error: String,
|
||||
},
|
||||
@@ -177,6 +193,41 @@ pub enum WorkerReply {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// One full-resolution Roto Brush segmentation: its chain key and the base64 of
|
||||
/// [`effectcraft_track::roto::FrameSeg::to_bytes`].
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SegData {
|
||||
pub key: u64,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
impl SegData {
|
||||
pub fn new(key: u64, seg: &effectcraft_track::roto::FrameSeg) -> SegData {
|
||||
SegData { key, data: effectcraft_track::roto::rle::base64_encode(&seg.to_bytes()) }
|
||||
}
|
||||
|
||||
/// Put the segmentation into this instance's cache (what the Roto Brush effect renders from).
|
||||
pub fn store(&self) -> bool {
|
||||
let Some(seg) = effectcraft_track::roto::rle::base64_decode(&self.data).and_then(|b| effectcraft_track::roto::FrameSeg::from_bytes(&b)) else {
|
||||
return false;
|
||||
};
|
||||
effectcraft_effects::roto::store(self.key, 1.0, Arc::new(seg));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The full-resolution segmentations of `keys` that are cached here and not in `sent` yet.
|
||||
pub fn segs_to_send(keys: &[u64], sent: &mut std::collections::HashSet<u64>) -> Vec<SegData> {
|
||||
let new: Vec<(u64, Arc<effectcraft_track::roto::FrameSeg>)> =
|
||||
keys.iter().filter(|k| !sent.contains(k)).filter_map(|k| effectcraft_effects::roto::cached(*k, 1.0).map(|s| (*k, s))).collect();
|
||||
new.into_iter()
|
||||
.map(|(k, s)| {
|
||||
sent.insert(k);
|
||||
SegData::new(k, &s)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Replies waiting for the UI thread.
|
||||
#[derive(Default)]
|
||||
pub struct Inbox(Mutex<Vec<WorkerReply>>);
|
||||
@@ -292,6 +343,7 @@ fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> {
|
||||
WorkerJob::RotoFreeze { comp, layer, effect } => analysis(s, kind, target, post, |s| {
|
||||
s.start_roto(comp, layer, effect, crate::roto::RotoTask::Freeze, crate::roto::Direction::Both, true).map(|_| ())
|
||||
}),
|
||||
WorkerJob::RotoPropagate { comp, layer, effect, direction } => propagate(s, comp, layer, effect, direction, post),
|
||||
WorkerJob::Track { comp, layer, tracker, direction, settings, points, times } => analysis(s, kind, target, post, |s| {
|
||||
let work = crate::tracking::Work {
|
||||
project: s.project.clone(),
|
||||
@@ -323,6 +375,38 @@ fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Roto Brush propagation in a worker: the segmentations stream back (at most every 250 ms)
|
||||
/// as they are computed, so stopping the job keeps what was propagated.
|
||||
fn propagate(s: &mut Session, comp: ItemId, layer: LayerId, effect: Uid, dir: crate::roto::Direction, post: Post) -> Result<(), String> {
|
||||
let keys: Vec<u64> = s.roto_chain(comp, layer, effect).map(|c| c.keys.values().copied().collect()).ok_or("no Roto Brush strokes")?;
|
||||
let sent = Rc::new(RefCell::new(std::collections::HashSet::new()));
|
||||
let (p, k2, s2) = (post.clone(), keys.clone(), sent.clone());
|
||||
let mut last = None::<web_time::Instant>;
|
||||
let hook = Box::new(move |done: u64, total: u64| {
|
||||
if last.is_none_or(|t: web_time::Instant| t.elapsed().as_millis() >= 250) {
|
||||
last = Some(web_time::Instant::now());
|
||||
let segs = segs_to_send(&k2, &mut s2.borrow_mut());
|
||||
if !segs.is_empty() {
|
||||
p(WorkerReply::Segs { segs });
|
||||
}
|
||||
p(WorkerReply::Progress { done, total });
|
||||
}
|
||||
});
|
||||
s.events.clear();
|
||||
with_hook(hook, || s.start_roto(comp, layer, effect, crate::roto::RotoTask::Propagate, dir, true))?;
|
||||
let segs = segs_to_send(&keys, &mut sent.borrow_mut());
|
||||
if !segs.is_empty() {
|
||||
post(WorkerReply::Segs { segs });
|
||||
}
|
||||
if let Some(e) = s.events.iter().find_map(|e| match e {
|
||||
Event::Toast { message, error: true } => Some(message.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn analysis(
|
||||
s: &mut Session,
|
||||
kind: JobKind,
|
||||
@@ -398,6 +482,8 @@ pub struct OffloadedJob {
|
||||
pub uid: Uid,
|
||||
pub inbox: Arc<Inbox>,
|
||||
pub progress: OffloadProgress,
|
||||
/// Segmentations received (Roto Brush propagation).
|
||||
pub segs: u64,
|
||||
started: web_time::Instant,
|
||||
}
|
||||
|
||||
@@ -432,7 +518,17 @@ impl Session {
|
||||
let inbox = Arc::new(Inbox::default());
|
||||
let req = WorkerRequest { id, project: self.project.to_json(), files: footage_files(&self.project), job };
|
||||
off.start(req, inbox.clone())?;
|
||||
self.offloaded.push(OffloadedJob { id, kind, comp, layer, uid, inbox, progress: OffloadProgress::default(), started: web_time::Instant::now() });
|
||||
self.offloaded.push(OffloadedJob {
|
||||
id,
|
||||
kind,
|
||||
comp,
|
||||
layer,
|
||||
uid,
|
||||
inbox,
|
||||
progress: OffloadProgress::default(),
|
||||
segs: 0,
|
||||
started: web_time::Instant::now(),
|
||||
});
|
||||
self.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
Ok(())
|
||||
}
|
||||
@@ -469,6 +565,12 @@ impl Session {
|
||||
j.progress.total = total;
|
||||
}
|
||||
WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)),
|
||||
WorkerReply::Segs { segs } => {
|
||||
for sd in &segs {
|
||||
sd.store();
|
||||
}
|
||||
j.segs += segs.len() as u64;
|
||||
}
|
||||
WorkerReply::Failed { error } => j.progress.error = Some(error),
|
||||
WorkerReply::Done => j.progress.finished = true,
|
||||
WorkerReply::Render { .. } | WorkerReply::Item { .. } => {}
|
||||
@@ -495,17 +597,25 @@ impl Session {
|
||||
let mut done = vec![];
|
||||
self.offloaded.retain(|j| {
|
||||
if j.progress.finished {
|
||||
done.push((j.kind, j.progress.clone()));
|
||||
done.push((j.kind, j.progress.clone(), j.segs));
|
||||
}
|
||||
!j.progress.finished
|
||||
});
|
||||
for (kind, p) in done {
|
||||
for (kind, p, segs) in done {
|
||||
changed = true;
|
||||
self.offload_log.push((kind, p.error.clone().or_else(|| p.cancelled.then(|| "stopped".to_string()))));
|
||||
if self.offload_log.len() > 32 {
|
||||
self.offload_log.remove(0);
|
||||
}
|
||||
if let Some(e) = p.error {
|
||||
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
|
||||
} else if p.cancelled {
|
||||
self.events.push(Event::Toast { message: format!("{} stopped", kind.label()), error: false });
|
||||
let kept = if segs > 0 { format!(" ({segs} frame(s) kept)") } else { String::new() };
|
||||
self.events.push(Event::Toast { message: format!("{} stopped{kept}", kind.label()), error: false });
|
||||
} else if kind == JobKind::RotoPropagate {
|
||||
self.events.push(Event::Toast { message: format!("Roto Brush: propagated {} frame(s) in {:.1} s", segs, p.elapsed), error: false });
|
||||
}
|
||||
// Rendered frames depend on the result (an analysis' group, the segmentation cache).
|
||||
self.bump();
|
||||
}
|
||||
changed
|
||||
@@ -567,6 +677,7 @@ mod tests {
|
||||
WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 },
|
||||
WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 },
|
||||
WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 },
|
||||
WorkerJob::RotoPropagate { comp, layer: LayerId(3), effect: 9, direction: crate::roto::Direction::Forward },
|
||||
WorkerJob::Track {
|
||||
comp,
|
||||
layer: LayerId(1),
|
||||
@@ -606,6 +717,7 @@ mod tests {
|
||||
WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } },
|
||||
WorkerReply::Progress { done: 1, total: 2 },
|
||||
WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() },
|
||||
WorkerReply::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] },
|
||||
WorkerReply::Failed { error: "boom".into() },
|
||||
WorkerReply::Done,
|
||||
] {
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Viewer frames rendered by another engine instance (the browser's frame worker).
|
||||
//!
|
||||
//! The page keeps no frame threads (a wasm build without shared memory has one thread per
|
||||
//! instance), so the web app renders viewer frames in a long-lived Web Worker that holds a
|
||||
//! replica of the project. The page sends the replica **project diffs** rather than the whole
|
||||
//! project per edit, then render requests; the worker answers each with the frame's pixels.
|
||||
//!
|
||||
//! - [`diff`] / [`patch`]: a structural JSON diff of the serialized project (object members
|
||||
//! added, changed or removed; arrays element-wise when their length is unchanged, else
|
||||
//! replaced). Lossless (unlike JSON Merge Patch, `null` values survive).
|
||||
//! - [`Mirror`] (page side): what the worker holds; turns the next project revision into a
|
||||
//! [`FrameMsg::Sync`] (a full project the first time, a diff afterwards).
|
||||
//! - [`FrameServer`] (worker side): applies syncs, relayed Roto Brush segmentations and render
|
||||
//! requests, and renders frames as premultiplied RGBA8 ([`rgba8_premultiplied`]).
|
||||
//!
|
||||
//! The transport (structured-clone messages, transferable pixel buffers) is the web crate's;
|
||||
//! everything here is portable and tested natively.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_project::{ItemId, Project};
|
||||
use effectcraft_render::{ExprHost, FootageSource, Image, LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::offload::SegData;
|
||||
|
||||
/// One step of a path into a JSON document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Step {
|
||||
Key(String),
|
||||
Index(usize),
|
||||
}
|
||||
|
||||
/// One change: set the value at `path` (creating or replacing the member/element), or remove
|
||||
/// the object member at `path` (`value` absent).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Op {
|
||||
pub path: Vec<Step>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub value: Option<Value>,
|
||||
}
|
||||
|
||||
/// The changes that turn `old` into `new` (empty when equal).
|
||||
pub fn diff(old: &Value, new: &Value) -> Vec<Op> {
|
||||
let mut ops = vec![];
|
||||
diff_into(old, new, &mut vec![], &mut ops);
|
||||
ops
|
||||
}
|
||||
|
||||
fn diff_into(old: &Value, new: &Value, path: &mut Vec<Step>, ops: &mut Vec<Op>) {
|
||||
if old == new {
|
||||
return;
|
||||
}
|
||||
match (old, new) {
|
||||
(Value::Object(a), Value::Object(b)) => {
|
||||
for (k, va) in a {
|
||||
path.push(Step::Key(k.clone()));
|
||||
match b.get(k) {
|
||||
Some(vb) => diff_into(va, vb, path, ops),
|
||||
None => ops.push(Op { path: path.clone(), value: None }),
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
for (k, vb) in b {
|
||||
if !a.contains_key(k) {
|
||||
path.push(Step::Key(k.clone()));
|
||||
ops.push(Op { path: path.clone(), value: Some(vb.clone()) });
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(a), Value::Array(b)) if a.len() == b.len() => {
|
||||
for (i, (va, vb)) in a.iter().zip(b).enumerate() {
|
||||
path.push(Step::Index(i));
|
||||
diff_into(va, vb, path, ops);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
_ => ops.push(Op { path: path.clone(), value: Some(new.clone()) }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply [`diff`]'s changes to `doc`.
|
||||
pub fn patch(doc: &mut Value, ops: &[Op]) -> Result<(), String> {
|
||||
for op in ops {
|
||||
let Some((last, parents)) = op.path.split_last() else {
|
||||
*doc = op.value.clone().ok_or("cannot remove the document")?;
|
||||
continue;
|
||||
};
|
||||
let mut cur = &mut *doc;
|
||||
for st in parents {
|
||||
cur = match (st, cur) {
|
||||
(Step::Key(k), Value::Object(m)) => m.get_mut(k).ok_or_else(|| format!("no member `{k}`"))?,
|
||||
(Step::Index(i), Value::Array(a)) => a.get_mut(*i).ok_or_else(|| format!("no element {i}"))?,
|
||||
_ => return Err("path does not match the document".into()),
|
||||
};
|
||||
}
|
||||
match (last, cur, &op.value) {
|
||||
(Step::Key(k), Value::Object(m), Some(v)) => {
|
||||
m.insert(k.clone(), v.clone());
|
||||
}
|
||||
(Step::Key(k), Value::Object(m), None) => {
|
||||
m.remove(k);
|
||||
}
|
||||
(Step::Index(i), Value::Array(a), Some(v)) => *a.get_mut(*i).ok_or_else(|| format!("no element {i}"))? = v.clone(),
|
||||
_ => return Err("path does not match the document".into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Page → frame worker.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum FrameMsg {
|
||||
/// The whole project (JSON) at `revision`.
|
||||
Project { revision: u64, project: String },
|
||||
/// Changes from the worker's revision `from` to `revision`.
|
||||
Patch { from: u64, revision: u64, ops: Vec<Op> },
|
||||
/// Render a frame of the current project.
|
||||
Render { id: u64, revision: u64, comp: ItemId, time: Tick, opts: Box<RenderOpts> },
|
||||
/// Roto Brush segmentations computed elsewhere (a propagation worker).
|
||||
Segs { segs: Vec<SegData> },
|
||||
/// Drop cached layer buffers (Edit ▸ Purge).
|
||||
Purge,
|
||||
}
|
||||
|
||||
/// Frame worker → page. A [`FrameReply::Frame`]'s pixels travel next to the message (a
|
||||
/// transferable buffer in the browser).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum FrameReply {
|
||||
/// Premultiplied RGBA8 pixels of request `id`, rendered in `ms` milliseconds.
|
||||
Frame { id: u64, width: u32, height: u32, ms: f64 },
|
||||
/// Request `id` was not rendered (`stale`: the worker holds another revision).
|
||||
Failed { id: u64, error: String, stale: bool },
|
||||
/// A sync could not be applied: the page must send the whole project again.
|
||||
Resync { error: String },
|
||||
}
|
||||
|
||||
/// The page's record of a worker's replica.
|
||||
#[derive(Default)]
|
||||
pub struct Mirror {
|
||||
sent: Option<(u64, Value)>,
|
||||
}
|
||||
|
||||
impl Mirror {
|
||||
/// The revision the worker holds.
|
||||
pub fn revision(&self) -> Option<u64> {
|
||||
self.sent.as_ref().map(|s| s.0)
|
||||
}
|
||||
|
||||
/// Forget the replica (the next sync sends the whole project).
|
||||
pub fn reset(&mut self) {
|
||||
self.sent = None;
|
||||
}
|
||||
|
||||
/// The message that brings the worker to `revision` of `project` (`None` when it has it).
|
||||
pub fn sync(&mut self, revision: u64, project: &Project) -> Option<FrameMsg> {
|
||||
if self.revision() == Some(revision) {
|
||||
return None;
|
||||
}
|
||||
let v = serde_json::to_value(project).unwrap_or(Value::Null);
|
||||
let msg = match self.sent.take() {
|
||||
Some((from, old)) => FrameMsg::Patch { from, revision, ops: diff(&old, &v) },
|
||||
None => FrameMsg::Project { revision, project: v.to_string() },
|
||||
};
|
||||
self.sent = Some((revision, v));
|
||||
Some(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied f32 → premultiplied RGBA8 (colour clamped to alpha), as the viewer shows it.
|
||||
pub fn rgba8_premultiplied(img: &Image) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(img.data.len() * 4);
|
||||
for p in &img.data {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let c = |v: f32| (v.clamp(0.0, a) * 255.0 + 0.5) as u8;
|
||||
out.extend_from_slice(&[c(p[0]), c(p[1]), c(p[2]), (a * 255.0 + 0.5) as u8]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The worker's side: a project replica and what rendering needs.
|
||||
pub struct FrameServer {
|
||||
pub footage: Arc<dyn FootageSource>,
|
||||
pub expr: Option<Arc<dyn ExprHost>>,
|
||||
pub cache: Arc<LayerCache>,
|
||||
doc: Value,
|
||||
project: Option<Arc<Project>>,
|
||||
revision: Option<u64>,
|
||||
}
|
||||
|
||||
impl FrameServer {
|
||||
pub fn new(footage: Arc<dyn FootageSource>, expr: Option<Arc<dyn ExprHost>>) -> FrameServer {
|
||||
FrameServer { footage, expr, cache: Arc::new(LayerCache::default()), doc: Value::Null, project: None, revision: None }
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> Option<u64> {
|
||||
self.revision
|
||||
}
|
||||
|
||||
pub fn project(&self) -> Option<&Arc<Project>> {
|
||||
self.project.as_ref()
|
||||
}
|
||||
|
||||
fn load(&mut self, revision: u64) -> Result<(), String> {
|
||||
let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?;
|
||||
self.project = Some(Arc::new(p));
|
||||
self.revision = Some(revision);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle one message; returns the replies with their pixels.
|
||||
pub fn handle(&mut self, msg: FrameMsg) -> Vec<(FrameReply, Option<Vec<u8>>)> {
|
||||
match msg {
|
||||
FrameMsg::Project { revision, project } => {
|
||||
let r = serde_json::from_str(&project).map_err(|e| e.to_string()).and_then(|v| {
|
||||
self.doc = v;
|
||||
self.load(revision)
|
||||
});
|
||||
match r {
|
||||
Ok(()) => vec![],
|
||||
Err(e) => self.lost(e),
|
||||
}
|
||||
}
|
||||
FrameMsg::Patch { from, revision, ops } => {
|
||||
if self.revision != Some(from) {
|
||||
return self.lost(format!("the replica is at {:?}, the patch starts at {from}", self.revision));
|
||||
}
|
||||
match patch(&mut self.doc, &ops).and_then(|_| self.load(revision)) {
|
||||
Ok(()) => vec![],
|
||||
Err(e) => self.lost(e),
|
||||
}
|
||||
}
|
||||
FrameMsg::Segs { segs } => {
|
||||
for s in &segs {
|
||||
s.store();
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
FrameMsg::Purge => {
|
||||
self.cache.clear();
|
||||
vec![]
|
||||
}
|
||||
FrameMsg::Render { id, revision, comp, time, opts } => {
|
||||
let fail = |error: String, stale: bool| vec![(FrameReply::Failed { id, error, stale }, None)];
|
||||
if self.revision != Some(revision) {
|
||||
return fail(format!("the replica is at {:?}, not {revision}", self.revision), true);
|
||||
}
|
||||
let Some(p) = self.project.clone() else { return fail("no project".into(), true) };
|
||||
if p.comp(comp).is_none() {
|
||||
return fail(format!("no composition {}", comp.0), false);
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let mut r = Renderer::new(&p, self.footage.as_ref(), *opts);
|
||||
r.expr = self.expr.as_deref();
|
||||
r.cache = Some(&self.cache);
|
||||
let img = r.comp_frame(comp, time);
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
vec![(FrameReply::Frame { id, width: img.width, height: img.height, ms }, Some(rgba8_premultiplied(&img)))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lost(&mut self, error: String) -> Vec<(FrameReply, Option<Vec<u8>>)> {
|
||||
self.doc = Value::Null;
|
||||
self.project = None;
|
||||
self.revision = None;
|
||||
vec![(FrameReply::Resync { error }, None)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn round<T: Serialize + for<'de> Deserialize<'de>>(v: &T) -> T {
|
||||
serde_json::from_str(&serde_json::to_string(v).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_and_patch_round_trip() {
|
||||
let a = json!({"a": 1, "b": [1, 2, {"c": null}], "d": {"e": "x", "f": [1]}, "g": null});
|
||||
let b = json!({"a": 2, "b": [1, 3, {"c": 4}], "d": {"f": [1, 2]}, "g": null, "h": {"i": null}});
|
||||
let ops = diff(&a, &b);
|
||||
assert!(!ops.is_empty());
|
||||
let mut x = a.clone();
|
||||
patch(&mut x, &round(&ops)).unwrap();
|
||||
assert_eq!(x, b);
|
||||
// Removal and the reverse direction.
|
||||
let mut y = b.clone();
|
||||
patch(&mut y, &diff(&b, &a)).unwrap();
|
||||
assert_eq!(y, a);
|
||||
assert!(diff(&a, &a).is_empty());
|
||||
// A whole-document change.
|
||||
let mut z = json!(1);
|
||||
patch(&mut z, &diff(&json!(1), &json!([1]))).unwrap();
|
||||
assert_eq!(z, json!([1]));
|
||||
// A patch for another document fails cleanly.
|
||||
assert!(patch(&mut json!(3), &diff(&a, &b)).is_err());
|
||||
}
|
||||
|
||||
fn server(s: &Session) -> FrameServer {
|
||||
FrameServer::new(s.footage.clone(), s.expr.clone())
|
||||
}
|
||||
|
||||
/// Pixels the page itself would render.
|
||||
fn local(s: &Session, comp: ItemId, t: Tick, opts: RenderOpts) -> Vec<u8> {
|
||||
let mut r = Renderer::new(&s.project, s.footage.as_ref(), opts);
|
||||
r.expr = s.expr.as_deref();
|
||||
rgba8_premultiplied(&r.comp_frame(comp, t))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replica_follows_edits_through_diffs_and_renders_the_same_frames() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let comp = s.active_comp_id().unwrap();
|
||||
let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() };
|
||||
let mut m = Mirror::default();
|
||||
let mut w = server(&s);
|
||||
let send = |m: FrameMsg, w: &mut FrameServer| w.handle(round(&m));
|
||||
// First sync: the whole project.
|
||||
let first = m.sync(s.revision, &s.project).unwrap();
|
||||
assert!(matches!(first, FrameMsg::Project { .. }));
|
||||
assert!(send(first, &mut w).is_empty());
|
||||
assert!(m.sync(s.revision, &s.project).is_none(), "nothing new");
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let render = |w: &mut FrameServer, rev: u64, id: u64| w.handle(round(&FrameMsg::Render { id, revision: rev, comp, time: t, opts: Box::new(opts) }));
|
||||
let out = render(&mut w, s.revision, 1);
|
||||
let (FrameReply::Frame { id, width, height, .. }, Some(px)) = &out[0] else { panic!("{:?}", out[0].0) };
|
||||
assert_eq!((*id, *width as usize * *height as usize * 4), (1, px.len()));
|
||||
assert_eq!(*px, local(&s, comp, t, opts));
|
||||
// An edit travels as a small diff; the replica renders the edited frame.
|
||||
s.execute("layer.newSolid", json!({"color": "#ff8800", "name": "Orange"})).unwrap();
|
||||
let msg = m.sync(s.revision, &s.project).unwrap();
|
||||
let FrameMsg::Patch { ops, .. } = &msg else { panic!("expected a patch") };
|
||||
assert!(!ops.is_empty() && serde_json::to_string(ops).unwrap().len() < s.project.to_json().len() / 2);
|
||||
assert!(send(msg, &mut w).is_empty());
|
||||
assert_eq!(**w.project().unwrap(), *s.project);
|
||||
let out = render(&mut w, s.revision, 2);
|
||||
assert_eq!(out[0].1.as_deref(), Some(&local(&s, comp, t, opts)[..]));
|
||||
// A request for another revision is refused as stale.
|
||||
let out = render(&mut w, s.revision + 7, 3);
|
||||
assert!(matches!(out[0].0, FrameReply::Failed { id: 3, stale: true, .. }));
|
||||
// A patch from the wrong base asks for a resync; the mirror then sends the project.
|
||||
let bad = FrameMsg::Patch { from: 999, revision: 1000, ops: vec![] };
|
||||
assert!(matches!(send(bad, &mut w)[0].0, FrameReply::Resync { .. }));
|
||||
m.reset();
|
||||
assert!(matches!(m.sync(s.revision, &s.project), Some(FrameMsg::Project { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_messages_round_trip() {
|
||||
for m in [
|
||||
FrameMsg::Purge,
|
||||
FrameMsg::Segs { segs: vec![SegData { key: 7, data: "AAAA".into() }] },
|
||||
FrameMsg::Render {
|
||||
id: 3,
|
||||
revision: 4,
|
||||
comp: ItemId(2),
|
||||
time: Tick::from_seconds_f64(0.5),
|
||||
opts: Box::new(RenderOpts { scale: 0.5, draft: true, roi: Some([1.0, 2.0, 3.0, 4.0]), ..Default::default() }),
|
||||
},
|
||||
] {
|
||||
let a = serde_json::to_string(&m).unwrap();
|
||||
assert_eq!(serde_json::to_string(&round(&m)).unwrap(), a);
|
||||
assert!(a.starts_with("{\"type\":"), "{a}");
|
||||
}
|
||||
for r in [
|
||||
FrameReply::Frame { id: 1, width: 2, height: 3, ms: 4.5 },
|
||||
FrameReply::Failed { id: 1, error: "x".into(), stale: true },
|
||||
FrameReply::Resync { error: "y".into() },
|
||||
] {
|
||||
assert_eq!(round(&r), r);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-12
@@ -3,7 +3,8 @@
|
||||
//!
|
||||
//! Segmentations are derived data in `effectcraft_effects::roto`'s content-keyed cache (see that
|
||||
//! module). `roto.propagate` fills it at full resolution in the background (or blocking with
|
||||
//! `wait`), frame by frame outward from the base frame, rendering the effect's input
|
||||
//! `wait`; in the browser the background is a Web Worker whose segmentations stream back,
|
||||
//! [`crate::offload`]), frame by frame outward from the base frame, rendering the effect's input
|
||||
//! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same
|
||||
//! pass and stores the final mattes in the effect (one undo step). [`sync`], run after every
|
||||
//! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made
|
||||
@@ -403,16 +404,21 @@ pub(crate) fn job_frames(d: &effectcraft_track::roto::RotoData, dir: Direction)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The Roto Brush job running in a worker (Freeze or propagation), with its task.
|
||||
fn offloaded_roto(&self) -> Option<(&crate::offload::OffloadedJob, RotoTask)> {
|
||||
self.offloaded(JobKind::RotoFreeze).map(|j| (j, RotoTask::Freeze)).or_else(|| self.offloaded(JobKind::RotoPropagate).map(|j| (j, RotoTask::Propagate)))
|
||||
}
|
||||
|
||||
pub fn is_roto_running(&self) -> bool {
|
||||
self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::RotoFreeze).is_some()
|
||||
self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded_roto().is_some()
|
||||
}
|
||||
|
||||
pub fn roto_progress(&self) -> Option<RotoProgress> {
|
||||
self.roto_job.as_ref().map(|j| j.progress()).or_else(|| {
|
||||
self.offloaded(JobKind::RotoFreeze).map(|j| {
|
||||
self.offloaded_roto().map(|(j, task)| {
|
||||
let p = &j.progress;
|
||||
RotoProgress {
|
||||
task: Some(RotoTask::Freeze),
|
||||
task: Some(task),
|
||||
done: p.done,
|
||||
total: p.total,
|
||||
elapsed: p.elapsed,
|
||||
@@ -425,14 +431,22 @@ impl Session {
|
||||
|
||||
/// (comp, layer, effect uid, task) of the running job.
|
||||
pub fn roto_target(&self) -> Option<(ItemId, LayerId, Uid, RotoTask)> {
|
||||
self.roto_job
|
||||
.as_ref()
|
||||
.map(|j| (j.comp, j.layer, j.effect, j.task))
|
||||
.or_else(|| self.offloaded(JobKind::RotoFreeze).map(|j| (j.comp, j.layer, j.uid, RotoTask::Freeze)))
|
||||
self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task)).or_else(|| self.offloaded_roto().map(|(j, task)| (j.comp, j.layer, j.uid, task)))
|
||||
}
|
||||
|
||||
/// The segmentation chain a propagation of `effect` computes (its frames' cache keys).
|
||||
pub fn roto_chain(&self, comp: ItemId, layer: LayerId, effect: Uid) -> Option<fx::Chain> {
|
||||
let c = self.project.comp(comp)?;
|
||||
let l = c.layer(layer)?;
|
||||
let g = l.effects()?.groups().find(|g| g.uid == effect && is_roto(g))?;
|
||||
let d = fx::data(¶ms_static(g));
|
||||
let t = comp_time_of(c, l, d.base?);
|
||||
let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref());
|
||||
Some(fx::Chain::new(¶ms, layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0))
|
||||
}
|
||||
|
||||
pub fn stop_roto(&mut self) -> bool {
|
||||
if self.cancel_offloaded(JobKind::RotoFreeze) {
|
||||
if self.cancel_offloaded(JobKind::RotoFreeze) || self.cancel_offloaded(JobKind::RotoPropagate) {
|
||||
return true;
|
||||
}
|
||||
match &self.roto_job {
|
||||
@@ -491,9 +505,12 @@ impl Session {
|
||||
task,
|
||||
};
|
||||
let strokes = str_of(g, fx::STROKES);
|
||||
if !wait && task == RotoTask::Freeze && self.offloads() {
|
||||
if !wait && self.offloads() {
|
||||
drop(work);
|
||||
self.offload_analysis(crate::offload::WorkerJob::RotoFreeze { comp, layer, effect })?;
|
||||
self.offload_analysis(match task {
|
||||
RotoTask::Freeze => crate::offload::WorkerJob::RotoFreeze { comp, layer, effect },
|
||||
RotoTask::Propagate => crate::offload::WorkerJob::RotoPropagate { comp, layer, effect, direction: dir },
|
||||
})?;
|
||||
return Ok(n);
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
@@ -567,7 +584,7 @@ impl Session {
|
||||
/// instances in the background.
|
||||
pub fn poll_roto(&mut self, auto: bool) -> bool {
|
||||
let changed = self.poll_roto_job();
|
||||
if auto && self.roto_job.is_none() && self.state.roto.auto_propagate {
|
||||
if auto && self.roto_job.is_none() && self.offloaded_roto().is_none() && self.state.roto.auto_propagate {
|
||||
while let Some((c, l, e)) = self.roto_pending.first().copied() {
|
||||
self.roto_pending.remove(0);
|
||||
if self.start_roto(c, l, e, RotoTask::Propagate, Direction::Both, false).is_ok() {
|
||||
|
||||
@@ -48,8 +48,12 @@ impl Session {
|
||||
if !s.is_empty() {
|
||||
return Some(std::path::PathBuf::from(s));
|
||||
}
|
||||
// (the browser has no file system: no default folder there)
|
||||
if self.config.is_none() || cfg!(target_arch = "wasm32") {
|
||||
return None;
|
||||
}
|
||||
let base = effectcraft_render::disk_cache::default_folder();
|
||||
self.config.is_some().then(|| base.parent().map(|p| p.join(name)).unwrap_or_else(|| base.join(name)))
|
||||
Some(base.parent().map(|p| p.join(name)).unwrap_or_else(|| base.join(name)))
|
||||
}
|
||||
|
||||
/// Settings ▸ Disk ▸ Database and Cache Folder (media cache: audio waveform summaries).
|
||||
|
||||
@@ -516,3 +516,64 @@ fn panels_commands_are_wired() {
|
||||
assert!(s.is_enabled(id), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A browser like the web app's: a virtual tree whose files are read on demand.
|
||||
#[derive(Default)]
|
||||
struct VirtualBrowser {
|
||||
tree: Mutex<crate::media_browser::VirtualTree>,
|
||||
fetched: Mutex<Vec<String>>,
|
||||
actions: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl crate::media_browser::Browser for VirtualBrowser {
|
||||
fn home(&self) -> String {
|
||||
"/Browser Storage".into()
|
||||
}
|
||||
fn places(&self) -> Vec<(String, String)> {
|
||||
vec![("Browser Storage".into(), self.home()), ("Shoot".into(), "/Folders/Shoot".into())]
|
||||
}
|
||||
fn list(&self, dir: &str, only: bool) -> Result<Vec<crate::media_browser::Entry>, String> {
|
||||
self.tree.lock().unwrap().list(dir, only)
|
||||
}
|
||||
fn fetch(&self, paths: &[String], _: &Value) -> bool {
|
||||
// Opened folders' files arrive later.
|
||||
let pending: Vec<String> = paths.iter().filter(|p| p.starts_with("/Folders/")).cloned().collect();
|
||||
self.fetched.lock().unwrap().extend(pending.iter().cloned());
|
||||
pending.is_empty()
|
||||
}
|
||||
fn actions(&self) -> Vec<(String, String)> {
|
||||
vec![("openFolder".into(), "Open Folder…".into())]
|
||||
}
|
||||
fn action(&self, id: &str) -> Result<Value, String> {
|
||||
self.actions.lock().unwrap().push(id.into());
|
||||
Ok(json!({"pending": true}))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_browser_over_a_virtual_tree() {
|
||||
let b = Arc::new(VirtualBrowser::default());
|
||||
{
|
||||
let mut t = b.tree.lock().unwrap();
|
||||
t.add_dir("/Browser Storage");
|
||||
t.add_file("/Folders/Shoot/a.csv", "/Folders/Shoot/a.csv", 12, Some(1));
|
||||
}
|
||||
let mut s = Session { browser: Some(b.clone()), ..Default::default() };
|
||||
let r = s.execute("mediaBrowser.list", json!({})).unwrap();
|
||||
assert_eq!(r["path"], "/Browser Storage");
|
||||
assert_eq!(r["places"][1]["path"], "/Folders/Shoot");
|
||||
assert_eq!(r["actions"][0]["id"], "openFolder");
|
||||
let r = s.execute("mediaBrowser.go", json!({"path": "/Folders/Shoot"})).unwrap();
|
||||
assert_eq!(r["entries"][0]["name"], "a.csv");
|
||||
assert_eq!(r["parent"], "/Folders");
|
||||
assert!(s.execute("mediaBrowser.go", json!({"path": "/Nope"})).is_err());
|
||||
// Importing a file that is not read yet: pending (the host imports it once it arrives).
|
||||
let r = s.execute("mediaBrowser.import", json!({"paths": ["/Folders/Shoot/a.csv"]})).unwrap();
|
||||
assert_eq!(r["pending"], true);
|
||||
assert_eq!(*b.fetched.lock().unwrap(), ["/Folders/Shoot/a.csv"]);
|
||||
assert!(s.project.items.values().all(|i| !matches!(i.kind, ItemKind::Footage(_))));
|
||||
let r = s.execute("mediaBrowser.action", json!({"action": "openFolder"})).unwrap();
|
||||
assert_eq!(r["pending"], true);
|
||||
assert_eq!(*b.actions.lock().unwrap(), ["openFolder"]);
|
||||
assert!(s.execute("mediaBrowser.action", json!({})).is_err());
|
||||
}
|
||||
|
||||
@@ -286,3 +286,52 @@ fn propagation_is_deterministic_and_runs_in_background() {
|
||||
let r = s.execute("roto.stroke", json!({"layer": clip.0, "points": [[90, 110]]})).unwrap();
|
||||
assert_eq!(r["radius"], 15.0);
|
||||
}
|
||||
|
||||
/// Runs offloaded jobs at once in a second session sharing the footage, through JSON (what the
|
||||
/// browser's Web Worker does, minus the thread).
|
||||
struct InlineWorker(std::sync::Arc<dyn effectcraft_render::FootageSource>);
|
||||
|
||||
impl crate::offload::Offload for InlineWorker {
|
||||
fn start(&self, req: crate::offload::WorkerRequest, inbox: std::sync::Arc<crate::offload::Inbox>) -> Result<(), String> {
|
||||
let req: crate::offload::WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
||||
let mut w = Session { footage: self.0.clone(), ..Default::default() };
|
||||
let post: crate::offload::Post = std::rc::Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
|
||||
crate::offload::run_request(&mut w, req, &post);
|
||||
Ok(())
|
||||
}
|
||||
fn cancel(&self, _: u64) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offloaded_propagation_streams_segmentations_back() {
|
||||
let (mut s, clip, uid) = painted(center, "disk-offload.mov");
|
||||
s.execute("roto.span", json!({"layer": clip.0, "end": 5})).unwrap();
|
||||
s.offload = Some(std::sync::Arc::new(InlineWorker(s.footage.clone())));
|
||||
let r = s.execute("roto.propagate", json!({"layer": clip.0})).unwrap();
|
||||
assert_eq!(r["frames"], 6, "{r}");
|
||||
assert!(s.roto_job.is_none(), "offloaded, not on a thread");
|
||||
assert!(s.is_roto_running());
|
||||
assert_eq!(s.roto_progress().unwrap().task, Some(crate::roto::RotoTask::Propagate));
|
||||
assert_eq!(s.roto_target().map(|t| (t.1, t.2)), Some((clip, uid)));
|
||||
assert!(s.jobs().iter().any(|j| j.id == "roto"));
|
||||
// The worker has its own segmentation cache: here nothing is computed until its replies
|
||||
// are applied.
|
||||
forget(&s, clip, uid);
|
||||
assert_eq!(s.execute("roto.status", json!({"layer": clip.0})).unwrap()["computed"], json!([]));
|
||||
let rev = s.revision;
|
||||
assert!(s.poll_offload());
|
||||
assert!(!s.is_roto_running());
|
||||
assert!(s.revision > rev, "frames are redrawn");
|
||||
let st = s.execute("roto.status", json!({"layer": clip.0})).unwrap();
|
||||
assert_eq!(st["computed"], json!([0, 1, 2, 3, 4, 5]), "{st}");
|
||||
assert!(s.events.iter().any(|e| matches!(e, crate::Event::Toast { message, error: false } if message.contains("propagated 6 frame"))));
|
||||
// The matte matches one computed here.
|
||||
let a = s.execute("roto.status", json!({"layer": clip.0, "frame": 5, "matte": true})).unwrap()["matte"].clone();
|
||||
forget(&s, clip, uid);
|
||||
let b = s.execute("roto.status", json!({"layer": clip.0, "frame": 5, "matte": true})).unwrap()["matte"].clone();
|
||||
assert!(a.is_string());
|
||||
assert_eq!(a, b);
|
||||
// `wait` still runs here.
|
||||
s.execute("roto.propagate", json!({"layer": clip.0, "wait": true})).unwrap();
|
||||
assert!(s.offloaded.is_empty());
|
||||
}
|
||||
|
||||
@@ -320,7 +320,8 @@ impl FxTarget for CpuFx<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RenderOpts {
|
||||
/// Output scale relative to comp pixels (1 = Full, 0.5 = Half…).
|
||||
pub scale: f64,
|
||||
|
||||
@@ -230,6 +230,61 @@ impl FrameSeg {
|
||||
}
|
||||
(n > 0.0).then(|| [sx / n, sy / n])
|
||||
}
|
||||
|
||||
/// A compact binary form (to ship a segmentation between engine instances, e.g. from a Web
|
||||
/// Worker to the page): `ECSG`, width, height (u32 LE), the colour models as JSON (length
|
||||
/// prefixed), then the matte and the Refine Edge band run-length encoded (u16 LE run, byte).
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let models = serde_json::to_vec(&(&self.fg, &self.bg)).unwrap_or_default();
|
||||
let mut out = Vec::with_capacity(16 + models.len() + 64);
|
||||
out.extend_from_slice(b"ECSG");
|
||||
out.extend_from_slice(&(self.w as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(self.h as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(models.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&models);
|
||||
for plane in [&self.matte, &self.refine] {
|
||||
let mut i = 0;
|
||||
while i < plane.len() {
|
||||
let v = plane[i];
|
||||
let mut n = 1;
|
||||
while i + n < plane.len() && plane[i + n] == v && n < u16::MAX as usize {
|
||||
n += 1;
|
||||
}
|
||||
out.extend_from_slice(&(n as u16).to_le_bytes());
|
||||
out.push(v);
|
||||
i += n;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inverse of [`FrameSeg::to_bytes`] (`None` for malformed input).
|
||||
pub fn from_bytes(b: &[u8]) -> Option<FrameSeg> {
|
||||
if b.len() < 16 || &b[..4] != b"ECSG" {
|
||||
return None;
|
||||
}
|
||||
let u = |i: usize| u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize;
|
||||
let (w, h, ml) = (u(4), u(8), u(12));
|
||||
let n = w.checked_mul(h)?;
|
||||
let models = b.get(16..16 + ml)?;
|
||||
let (fg, bg): (Gmm, Gmm) = serde_json::from_slice(models).ok()?;
|
||||
let mut rest = &b[16 + ml..];
|
||||
let mut plane = || -> Option<Vec<u8>> {
|
||||
let mut v = Vec::with_capacity(n);
|
||||
while v.len() < n {
|
||||
if rest.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let run = u16::from_le_bytes([rest[0], rest[1]]) as usize;
|
||||
v.resize(v.len() + run, rest[2]);
|
||||
rest = &rest[3..];
|
||||
}
|
||||
(v.len() == n).then_some(v)
|
||||
};
|
||||
let matte = plane()?;
|
||||
let refine = plane()?;
|
||||
Some(FrameSeg { w, h, matte, refine, fg, bg })
|
||||
}
|
||||
}
|
||||
|
||||
/// Straight RGB of an image (transparent pixels are black).
|
||||
|
||||
@@ -89,6 +89,12 @@ fn strokes_segment_the_base_frame() {
|
||||
// Deterministic.
|
||||
let again = segment(&img, &refs, None, &SegOpts::default(), 0.0);
|
||||
assert_eq!(seg, again);
|
||||
// The binary form round-trips exactly and is compact (run-length encoded planes).
|
||||
let bytes = seg.to_bytes();
|
||||
assert_eq!(FrameSeg::from_bytes(&bytes).as_ref(), Some(&seg));
|
||||
assert!(bytes.len() < seg.w * seg.h / 4, "{} bytes", bytes.len());
|
||||
assert!(FrameSeg::from_bytes(&bytes[..bytes.len() - 1]).is_none());
|
||||
assert!(FrameSeg::from_bytes(b"nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -55,6 +55,87 @@ pub enum Outcome {
|
||||
path: Option<String>,
|
||||
crop: Option<[f32; 4]>,
|
||||
},
|
||||
/// Reply once background jobs end (a `wait: true` command whose job runs in a worker).
|
||||
AwaitJobs(JobWaiter),
|
||||
}
|
||||
|
||||
/// A `wait: true` engine command whose job was sent to the session's offload (the browser's
|
||||
/// Web Workers) instead of blocking the UI thread: the reply goes out when the job ends.
|
||||
pub struct JobWaiter {
|
||||
pub command: String,
|
||||
pub params: Value,
|
||||
/// The command's own result (with `wait: false`).
|
||||
pub result: Value,
|
||||
/// Ids of the jobs it started (`jobs.list` ids: `render`, `track`, `roto`…).
|
||||
pub jobs: Vec<String>,
|
||||
/// Length of [`effectcraft_engine::Session::offload_log`] when it started.
|
||||
pub log_len: usize,
|
||||
}
|
||||
|
||||
/// Commands that block with `wait` (and its default).
|
||||
fn waits(command: &str, params: &Value) -> bool {
|
||||
let default = command == "renderQueue.render";
|
||||
params.get("wait").and_then(Value::as_bool).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Run an engine command. A waiting command (`wait: true`) whose job the session offloads
|
||||
/// runs with `wait: false` instead, and the reply waits for the job ([`Outcome::AwaitJobs`]),
|
||||
/// so the browser's UI keeps drawing while it runs.
|
||||
fn execute_engine(app: &mut EffectcraftApp, id: &str, params: Value) -> Result<Outcome, String> {
|
||||
if !app.session.offloads() || !waits(id, ¶ms) {
|
||||
return app.session.execute_checked(id, params).map(ok).map_err(|e| e.to_string());
|
||||
}
|
||||
let before: Vec<String> = app.session.jobs().into_iter().map(|j| j.id).collect();
|
||||
let log_len = app.session.offload_log.len();
|
||||
let mut p = params.clone();
|
||||
p["wait"] = json!(false);
|
||||
let result = app.session.execute_checked(id, p).map_err(|e| e.to_string())?;
|
||||
let jobs: Vec<String> = app.session.jobs().into_iter().map(|j| j.id).filter(|j| !before.contains(j)).collect();
|
||||
if jobs.is_empty() {
|
||||
return Ok(ok(result));
|
||||
}
|
||||
Ok(Outcome::AwaitJobs(JobWaiter { command: id.to_string(), params, result, jobs, log_len }))
|
||||
}
|
||||
|
||||
impl JobWaiter {
|
||||
/// Whether its jobs are still running.
|
||||
pub fn pending(&self, app: &EffectcraftApp) -> bool {
|
||||
app.session.jobs().iter().any(|j| self.jobs.contains(&j.id))
|
||||
}
|
||||
|
||||
/// The reply: what the blocking command would have returned (`running: false`, the final
|
||||
/// progress, render items and analysis status), or the job's error.
|
||||
pub fn finish(self, app: &mut EffectcraftApp) -> Value {
|
||||
let s = &mut app.session;
|
||||
s.poll_render();
|
||||
let errors: Vec<String> = s.offload_log.iter().skip(self.log_len).filter_map(|(_, e)| e.clone()).collect();
|
||||
if let Some(e) = errors.first() {
|
||||
return json!({"ok": false, "error": e});
|
||||
}
|
||||
let mut r = self.result;
|
||||
if r.get("running").is_some() {
|
||||
r["running"] = json!(false);
|
||||
}
|
||||
if self.command == "renderQueue.render" {
|
||||
let ids: Vec<Value> = r["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default();
|
||||
if let Ok(list) = s.execute("renderQueue.list", json!({})) {
|
||||
r["items"] =
|
||||
json!(list["items"].as_array().map(|a| a.iter().filter(|i| ids.contains(&i["id"])).cloned().collect::<Vec<_>>()).unwrap_or_default());
|
||||
}
|
||||
r["rendering"] = json!(s.is_rendering());
|
||||
}
|
||||
let prefix = self.command.split('.').next().unwrap_or("");
|
||||
if matches!(prefix, "warp" | "camera" | "roto")
|
||||
&& r.get("layer").is_some()
|
||||
&& let Ok(st) = s.execute(&format!("{prefix}.status"), json!({"layer": r["layer"], "effect": r["effect"]}))
|
||||
{
|
||||
r["status"] = st;
|
||||
}
|
||||
if r.get("progress").is_some() {
|
||||
r["progress"] = Value::Null;
|
||||
}
|
||||
json!({"ok": true, "result": r})
|
||||
}
|
||||
}
|
||||
|
||||
fn ok(v: Value) -> Outcome {
|
||||
@@ -94,12 +175,12 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
|
||||
// `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`,
|
||||
// `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click.
|
||||
let r = if req.method == "engine.execute" && effectcraft_engine::find_command(id).is_some() {
|
||||
app.session.execute_checked(id, params).map_err(|e| e.to_string())
|
||||
execute_engine(app, id, params)
|
||||
} else {
|
||||
crate::menus::invoke(app, ctx, id, params)
|
||||
crate::menus::invoke(app, ctx, id, params).map(ok)
|
||||
};
|
||||
match r {
|
||||
Ok(v) => ok(v),
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
app.ui.status = e.clone();
|
||||
err(e)
|
||||
|
||||
@@ -722,6 +722,18 @@ pub enum DockAction {
|
||||
PanelMenu(PanelKind, egui::Pos2),
|
||||
/// A tab started being dragged (re-dock or undock).
|
||||
BeginDrag(PanelKind),
|
||||
/// The tab's lock was toggled (Composition / Timeline viewer lock).
|
||||
ToggleLock(PanelKind),
|
||||
}
|
||||
|
||||
/// After Effects-style extras on a tab that shows a composition (Composition, Timeline): a close
|
||||
/// button (×), the item's label colour as a small square and the viewer lock.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TabDeco {
|
||||
pub swatch: egui::Color32,
|
||||
pub locked: bool,
|
||||
/// Close button and lock (Composition, Timeline); Effect Controls shows just the swatch.
|
||||
pub viewer: bool,
|
||||
}
|
||||
|
||||
/// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging.
|
||||
@@ -807,6 +819,7 @@ pub fn draw_group_chrome(
|
||||
t: &Tokens,
|
||||
reg: &mut crate::automation::Registry,
|
||||
title: &dyn Fn(PanelKind) -> String,
|
||||
decos: &[(PanelKind, TabDeco)],
|
||||
) -> Vec<DockAction> {
|
||||
let mut actions = Vec::new();
|
||||
let painter = ui.painter().clone();
|
||||
@@ -833,7 +846,14 @@ pub fn draw_group_chrome(
|
||||
let label = title(*p);
|
||||
let galley = painter.layout_no_wrap(label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
|
||||
let menu_w = if is_active { 20.0 } else { 0.0 };
|
||||
let w = galley.size().x + 16.0 + menu_w;
|
||||
let deco = decos.iter().find(|(k, _)| k == p).map(|(_, d)| *d);
|
||||
// × (active tab only), swatch and lock before the label.
|
||||
let deco_w = match deco {
|
||||
Some(d) if d.viewer => (if is_active { 16.0 } else { 0.0 }) + 14.0 + 16.0,
|
||||
Some(_) => 14.0,
|
||||
None => 0.0,
|
||||
};
|
||||
let w = galley.size().x + 16.0 + menu_w + deco_w;
|
||||
if x + w > strip.max.x - 20.0 && i > g.active {
|
||||
let r = Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click());
|
||||
@@ -851,7 +871,44 @@ pub fn draw_group_chrome(
|
||||
actions.push(DockAction::BeginDrag(*p));
|
||||
}
|
||||
reg.add(&format!("panel.tab.{}", p.id()), tab, &label);
|
||||
let label_x = tab.min.x + 8.0;
|
||||
let mut label_x = tab.min.x + 8.0;
|
||||
if let Some(d) = deco {
|
||||
let mut dx = tab.min.x + 6.0;
|
||||
if is_active && d.viewer {
|
||||
let cr = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(10.0, 10.0));
|
||||
let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), i)), Sense::click());
|
||||
let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
let k = 3.5;
|
||||
painter.line_segment([cr.center() + vec2(-k, -k), cr.center() + vec2(k, k)], Stroke::new(1.2, cc));
|
||||
painter.line_segment([cr.center() + vec2(-k, k), cr.center() + vec2(k, -k)], Stroke::new(1.2, cc));
|
||||
reg.add(&format!("panel.tab.{}.close", p.id()), cr, "Close");
|
||||
if cresp.clicked() {
|
||||
actions.push(DockAction::Close(*p));
|
||||
}
|
||||
dx += 16.0;
|
||||
}
|
||||
let sw = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(9.0, 9.0));
|
||||
painter.rect_filled(sw, 1.0, d.swatch);
|
||||
dx += 14.0;
|
||||
label_x = dx;
|
||||
if d.viewer {
|
||||
let lr = Rect::from_center_size(pos2(dx + 6.0, text_y), vec2(12.0, 12.0));
|
||||
let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), i)), Sense::click());
|
||||
let lc = if d.locked {
|
||||
t.tab_text_active
|
||||
} else if lresp.hovered() {
|
||||
t.tab_text
|
||||
} else {
|
||||
t.text_faint
|
||||
};
|
||||
icons::paint(&painter, lr, Icon::Lock, lc);
|
||||
reg.add(&format!("panel.tab.{}.lock", p.id()), lr, if d.locked { "Unlock" } else { "Lock" });
|
||||
if lresp.clicked() {
|
||||
actions.push(DockAction::ToggleLock(*p));
|
||||
}
|
||||
label_x = dx + 16.0;
|
||||
}
|
||||
}
|
||||
let label_w = galley.size().x;
|
||||
let col = if is_active || resp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
painter.galley_with_override_text_color(pos2(label_x, text_y - galley.size().y / 2.0), galley, col);
|
||||
|
||||
@@ -35,6 +35,26 @@ impl EffectcraftApp {
|
||||
out
|
||||
}
|
||||
|
||||
/// The Composition and Timeline tabs' close button, label-colour swatch and viewer lock.
|
||||
fn tab_decos(&self) -> Vec<(PanelKind, dock::TabDeco)> {
|
||||
let Some(cid) = self.session.active_comp_id() else { return vec![] };
|
||||
let swatch = match self.session.project.item(cid).map(|i| i.label) {
|
||||
Some(l) if l != effectcraft_engine::color::Label::None => self.tokens.label(l),
|
||||
_ => self.tokens.text_faint,
|
||||
};
|
||||
let mut v: Vec<(PanelKind, dock::TabDeco)> = [PanelKind::Composition, PanelKind::Timeline]
|
||||
.into_iter()
|
||||
.map(|p| (p, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&p.id()), viewer: true }))
|
||||
.collect();
|
||||
// Effect Controls carries the selected layer's label colour.
|
||||
if let Some(l) = self.session.active_comp().and_then(|c| self.session.state.selected_layers.first().and_then(|id| c.layer(*id)))
|
||||
&& l.label != effectcraft_engine::color::Label::None
|
||||
{
|
||||
v.push((PanelKind::EffectControls, dock::TabDeco { swatch: self.tokens.label(l.label), locked: false, viewer: false }));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Settings ▸ Appearance ▸ Use Label Color for Related Tabs: the Composition and Timeline
|
||||
/// tabs carry their comp's label colour, Effect Controls and Properties the layer's.
|
||||
fn label_tab_marks(&self, ui: &egui::Ui) {
|
||||
@@ -44,12 +64,9 @@ impl EffectcraftApp {
|
||||
let Some(cid) = self.session.active_comp_id() else { return };
|
||||
let comp_label = self.session.project.item(cid).map(|i| i.label);
|
||||
let layer_label = self.session.active_comp().and_then(|c| self.session.state.selected_layers.first().and_then(|l| c.layer(*l))).map(|l| l.label);
|
||||
for (p, label) in [
|
||||
(PanelKind::Composition, comp_label),
|
||||
(PanelKind::Timeline, comp_label),
|
||||
(PanelKind::EffectControls, layer_label),
|
||||
(PanelKind::Properties, layer_label),
|
||||
] {
|
||||
// (the Composition, Timeline and Effect Controls tabs carry their swatch already)
|
||||
let _ = comp_label;
|
||||
for (p, label) in [(PanelKind::Properties, layer_label)] {
|
||||
let (Some(label), Some(e)) = (label, self.auto.find(&format!("panel.tab.{}", p.id()))) else { continue };
|
||||
if label == effectcraft_engine::color::Label::None {
|
||||
continue;
|
||||
@@ -131,9 +148,10 @@ impl EffectcraftApp {
|
||||
dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto);
|
||||
let mut actions = Vec::new();
|
||||
let titles = self.tab_titles();
|
||||
let decos = self.tab_decos();
|
||||
let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string());
|
||||
for g in &groups {
|
||||
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title));
|
||||
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title, &decos));
|
||||
}
|
||||
self.label_tab_marks(ui);
|
||||
if maximized.is_none() {
|
||||
@@ -174,6 +192,11 @@ impl EffectcraftApp {
|
||||
DockAction::BeginDrag(p) => ctx.data_mut(|d| {
|
||||
d.insert_temp(drag_id(), p);
|
||||
}),
|
||||
DockAction::ToggleLock(p) => {
|
||||
if !self.ui.locked_tabs.remove(&p.id()) {
|
||||
self.ui.locked_tabs.insert(p.id().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tab_drag(&ctx, &groups, &float_groups);
|
||||
@@ -213,7 +236,8 @@ impl EffectcraftApp {
|
||||
active: f.active.min(f.panels.len().saturating_sub(1)),
|
||||
stacked: None,
|
||||
};
|
||||
actions.extend(dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, title));
|
||||
let decos = self.tab_decos();
|
||||
actions.extend(dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, title, &decos));
|
||||
ui.painter().rect_stroke(r, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
if let Some(p) = g.panels.get(g.active).copied() {
|
||||
self.panel_body(ui, p, g.content);
|
||||
|
||||
+182
-23
@@ -4,8 +4,10 @@
|
||||
//! never waits for the compositor. Results land in a memory-budgeted cache keyed by (project
|
||||
//! revision, comp, frame, scale); the timeline draws the cached range as the green cache bar.
|
||||
//!
|
||||
//! wasm32 has no threads: jobs wait in the same priority queue and [`Frames::pump`] renders them
|
||||
//! on the UI thread between egui frames, within a time budget.
|
||||
//! wasm32 has no threads: jobs wait in the same priority queue and [`Frames::pump`] hands them
|
||||
//! to a [`RemoteFrames`] (the browser's frame worker, a second engine instance fed with project
|
||||
//! diffs, `effectcraft_engine::remote`) or, without one, renders them on the UI thread between
|
||||
//! egui frames, within a time budget.
|
||||
//!
|
||||
//! With the disk cache on (Settings ▸ Media & Disk Cache), every CPU-rendered frame is also
|
||||
//! written to disk under a content key (the comp's content hash, frame, scale, view and render
|
||||
@@ -163,6 +165,38 @@ fn frame_disk_key(keys: &ContentKeys, project: &Project, key: &FrameKey, opts_ha
|
||||
disk_cache::frame_key(content, key.frame, key.scale, key.view ^ opts_hash)
|
||||
}
|
||||
|
||||
/// A frame for a [`RemoteFrames`] renderer.
|
||||
pub struct RemoteJob {
|
||||
pub project: Arc<Project>,
|
||||
pub revision: u64,
|
||||
pub comp: ItemId,
|
||||
pub t: Tick,
|
||||
pub opts: RenderOpts,
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA8 pixels from a [`RemoteFrames`] renderer.
|
||||
pub struct RemoteFrame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
/// Render time (ms).
|
||||
pub ms: f64,
|
||||
}
|
||||
|
||||
/// Called once with a remote frame (or why there is none).
|
||||
pub type RemoteDone = Box<dyn FnOnce(Result<RemoteFrame, String>) + Send>;
|
||||
|
||||
/// Renders viewer frames elsewhere (the browser's frame worker), so the UI thread never does.
|
||||
pub trait RemoteFrames: Send + Sync {
|
||||
/// Frames it can render at the same time (0 when it is not usable: frames then render on
|
||||
/// the UI thread again).
|
||||
fn slots(&self) -> usize;
|
||||
/// Start rendering `job`; `done` runs when the frame arrives.
|
||||
fn start(&self, job: RemoteJob, done: RemoteDone);
|
||||
/// Drop cached intermediate results (Edit ▸ Purge).
|
||||
fn purge(&self) {}
|
||||
}
|
||||
|
||||
pub struct Frames {
|
||||
cache: Arc<Mutex<Cache>>,
|
||||
/// Keys queued or rendering.
|
||||
@@ -175,6 +209,9 @@ pub struct Frames {
|
||||
/// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout.
|
||||
pub last_ms: Arc<Mutex<f64>>,
|
||||
content_keys: ContentKeys,
|
||||
/// Where frames render instead of the UI thread (wasm32), and how many it is rendering.
|
||||
remote: Option<Arc<dyn RemoteFrames>>,
|
||||
remote_busy: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl Default for Frames {
|
||||
@@ -190,6 +227,8 @@ impl Default for Frames {
|
||||
ctx: None,
|
||||
last_ms: Arc::new(Mutex::new(0.0)),
|
||||
content_keys: Arc::default(),
|
||||
remote: None,
|
||||
remote_busy: Arc::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,6 +309,9 @@ impl Frames {
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
if let Some(r) = &self.remote {
|
||||
r.purge();
|
||||
}
|
||||
if let Ok(mut c) = self.cache.lock() {
|
||||
c.map.clear();
|
||||
c.order.clear();
|
||||
@@ -324,7 +366,7 @@ impl Frames {
|
||||
q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq });
|
||||
drop(q);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if self.remote.is_none() {
|
||||
let w = self.worker();
|
||||
// One spawn per job; each spawn renders whichever queued job matters most right now.
|
||||
self.pool.spawn(move || {
|
||||
@@ -349,13 +391,102 @@ impl Frames {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render queued frames on this thread, most important first, until the queue is empty or
|
||||
/// `budget` has passed (at least one frame). Returns whether frames are still queued. Only
|
||||
/// wasm32 needs this (native frames render on the pool); elsewhere it does nothing.
|
||||
/// Render frames with `remote` from now on (`None`: on this thread again).
|
||||
pub fn set_remote(&mut self, remote: Option<Arc<dyn RemoteFrames>>) {
|
||||
self.remote = remote;
|
||||
}
|
||||
|
||||
/// Whether frames go to a usable [`RemoteFrames`].
|
||||
pub fn remote_active(&self) -> bool {
|
||||
self.remote.as_ref().is_some_and(|r| r.slots() > 0)
|
||||
}
|
||||
|
||||
/// Frames the remote renderer renders at once (0 without one).
|
||||
pub fn remote_slots(&self) -> usize {
|
||||
self.remote.as_ref().map(|r| r.slots()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Frames being rendered remotely.
|
||||
pub fn remote_busy(&self) -> usize {
|
||||
self.remote_busy.lock().map(|b| *b).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Hand queued frames to the remote renderer while it has free slots, most important first
|
||||
/// (the viewer's frame, then prefetch in request order; a viewer frame requested while every
|
||||
/// slot is busy goes to the first one that frees up). Frames that the GPU compositor can keep
|
||||
/// on the GPU render here instead (no readback). Returns how many were started.
|
||||
pub fn dispatch_remote(&self) -> usize {
|
||||
let Some(remote) = self.remote.clone() else { return 0 };
|
||||
let slots = remote.slots();
|
||||
let w = self.worker();
|
||||
let mut started = 0;
|
||||
loop {
|
||||
let busy = self.remote_busy();
|
||||
if busy >= slots {
|
||||
break;
|
||||
}
|
||||
let job = {
|
||||
let Ok(mut q) = self.queue.lock() else { break };
|
||||
let Some(i) = Queue::best(&q.jobs) else { break };
|
||||
let job = q.jobs.swap_remove(i);
|
||||
q.running.insert(job.key);
|
||||
if job.urgent {
|
||||
q.urgent_running += 1;
|
||||
}
|
||||
job
|
||||
};
|
||||
if job.src.gpu.is_some() && job.src.gpu_display {
|
||||
let t0 = web_time::Instant::now();
|
||||
if let Some(img) = w.render_gpu(&job) {
|
||||
w.finish(&job, img, t0);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Ok(mut b) = self.remote_busy.lock() {
|
||||
*b += 1;
|
||||
}
|
||||
started += 1;
|
||||
let (key, urgent) = (job.key, job.urgent);
|
||||
let fw = self.worker();
|
||||
let busy = self.remote_busy.clone();
|
||||
let t0 = web_time::Instant::now();
|
||||
let rj = RemoteJob { project: job.src.project.clone(), revision: key.revision, comp: job.comp, t: job.t, opts: job.opts };
|
||||
remote.start(
|
||||
rj,
|
||||
Box::new(move |r: Result<RemoteFrame, String>| {
|
||||
if let Ok(mut b) = busy.lock() {
|
||||
*b = b.saturating_sub(1);
|
||||
}
|
||||
match r {
|
||||
Ok(f) => {
|
||||
let img = egui::ColorImage::from_rgba_premultiplied([f.width as usize, f.height as usize], &f.rgba);
|
||||
fw.finish_key(key, urgent, FrameImage::Cpu(Arc::new(img)), t0);
|
||||
}
|
||||
Err(e) => {
|
||||
// Not rendered (a stale revision, a lost worker): the viewer asks again.
|
||||
log::debug!("remote frame: {e}");
|
||||
fw.release(key, urgent);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
/// Render queued frames, most important first. With a usable [`RemoteFrames`] they are only
|
||||
/// handed over ([`Frames::dispatch_remote`]); otherwise they render on this thread until the
|
||||
/// queue is empty or `budget` has passed (at least one frame). Returns whether frames are
|
||||
/// still queued or rendering remotely. Only wasm32 needs this (native frames render on the
|
||||
/// pool); elsewhere it does nothing.
|
||||
pub fn pump(&self, budget: std::time::Duration) -> bool {
|
||||
if cfg!(not(target_arch = "wasm32")) {
|
||||
return false;
|
||||
}
|
||||
if self.remote_active() {
|
||||
self.dispatch_remote();
|
||||
return self.queue.lock().map(|q| !q.jobs.is_empty()).unwrap_or(false) || self.remote_busy() > 0;
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
let w = self.worker();
|
||||
while w.run_next() {
|
||||
@@ -380,12 +511,10 @@ struct Worker {
|
||||
impl Worker {
|
||||
/// Render the queued job that matters most right now. `false` when the queue was empty.
|
||||
fn run_next(&self) -> bool {
|
||||
let Worker { queue, cache, inflight, ctx, last, content_keys } = self;
|
||||
let Worker { queue, content_keys, .. } = self;
|
||||
let job = {
|
||||
let Ok(mut q) = queue.lock() else { return false };
|
||||
// Urgent first (newest), then prefetch in request order.
|
||||
let best = q.jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i);
|
||||
let Some(i) = best else { return false };
|
||||
let Some(i) = Queue::best(&q.jobs) else { return false };
|
||||
let job = q.jobs.swap_remove(i);
|
||||
q.running.insert(job.key);
|
||||
if job.urgent {
|
||||
@@ -407,27 +536,50 @@ impl Worker {
|
||||
ci
|
||||
}
|
||||
};
|
||||
if job.urgent
|
||||
&& let Ok(mut l) = last.lock()
|
||||
{
|
||||
self.finish(&job, ci, t0);
|
||||
true
|
||||
}
|
||||
|
||||
/// Store a finished frame and release its job.
|
||||
fn finish(&self, job: &Job, img: FrameImage, t0: web_time::Instant) {
|
||||
self.finish_key(job.key, job.urgent, img, t0);
|
||||
}
|
||||
|
||||
fn finish_key(&self, key: FrameKey, urgent: bool, img: FrameImage, t0: web_time::Instant) {
|
||||
if urgent && let Ok(mut l) = self.last.lock() {
|
||||
*l = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
}
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
c.insert(job.key, ci);
|
||||
if let Ok(mut c) = self.cache.lock() {
|
||||
c.insert(key, img);
|
||||
}
|
||||
if let Ok(mut inf) = inflight.lock() {
|
||||
inf.remove(&job.key);
|
||||
self.release(key, urgent);
|
||||
}
|
||||
|
||||
/// The job of `key` is no longer queued or running (rendered or dropped).
|
||||
fn release(&self, key: FrameKey, urgent: bool) {
|
||||
if let Ok(mut inf) = self.inflight.lock() {
|
||||
inf.remove(&key);
|
||||
}
|
||||
if let Ok(mut q) = queue.lock() {
|
||||
q.running.remove(&job.key);
|
||||
if job.urgent {
|
||||
q.urgent_running -= 1;
|
||||
if let Ok(mut q) = self.queue.lock() {
|
||||
q.running.remove(&key);
|
||||
if urgent {
|
||||
q.urgent_running = q.urgent_running.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
if let Some(ctx) = ctx {
|
||||
if let Some(ctx) = &self.ctx {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The frame on the GPU, kept there for the viewer, if the compositor can do it alone.
|
||||
fn render_gpu(&self, job: &Job) -> Option<FrameImage> {
|
||||
let g = job.src.gpu.as_ref()?;
|
||||
let mut r = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts);
|
||||
r.expr = job.src.expr.as_deref();
|
||||
r.cache = Some(&job.src.layer_cache);
|
||||
r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator);
|
||||
r.active_accel()?;
|
||||
g.render_display(&r, job.comp, job.t).map(|f| FrameImage::Gpu(Arc::new(f)))
|
||||
}
|
||||
|
||||
fn render_job(&self, job: &Job) -> FrameImage {
|
||||
@@ -465,3 +617,10 @@ struct Queue {
|
||||
urgent_running: usize,
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
/// The job that matters most: urgent first (newest), then prefetch in request order.
|
||||
fn best(jobs: &[Job]) -> Option<usize> {
|
||||
jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,8 @@ pub struct EffectcraftApp {
|
||||
pub(crate) deferred: Vec<(ControlRequest, f64)>,
|
||||
pub(crate) synthetic: Vec<egui::Event>,
|
||||
pub(crate) input_waiters: Vec<Sender<serde_json::Value>>,
|
||||
/// Control requests waiting for an offloaded job (`wait: true` in the browser).
|
||||
pub(crate) job_waiters: Vec<(control::JobWaiter, Sender<serde_json::Value>)>,
|
||||
queued_screenshots: Vec<(u64, f64, u32)>,
|
||||
pending_screenshots: Vec<(u64, Option<String>, Option<[f32; 4]>, Sender<serde_json::Value>, f64)>,
|
||||
next_token: u64,
|
||||
@@ -210,6 +212,7 @@ impl EffectcraftApp {
|
||||
deferred: vec![],
|
||||
synthetic: vec![],
|
||||
input_waiters: vec![],
|
||||
job_waiters: vec![],
|
||||
queued_screenshots: vec![],
|
||||
pending_screenshots: vec![],
|
||||
next_token: 1,
|
||||
@@ -847,8 +850,9 @@ impl EffectcraftApp {
|
||||
|
||||
fn frames_parallelism(&self) -> usize {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
// frames render one at a time on the UI thread (`Frames::pump`)
|
||||
return 1;
|
||||
// frame workers render one frame each; without them frames render one at a time
|
||||
// on the UI thread (`Frames::pump`)
|
||||
return self.frames.remote_slots().max(1);
|
||||
}
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4)
|
||||
}
|
||||
@@ -881,6 +885,10 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
control::Outcome::AfterInput => self.input_waiters.push(reply),
|
||||
control::Outcome::AwaitJobs(w) => {
|
||||
self.job_waiters.push((w, reply));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
control::Outcome::Screenshot { path, crop } => {
|
||||
// A covered or minimized window presents no frames (macOS skips its redraws), so
|
||||
// a screenshot would never arrive and nothing would tick its timeout.
|
||||
@@ -897,6 +905,28 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
self.control_rx = Some(rx);
|
||||
self.finish_job_waiters(ctx);
|
||||
}
|
||||
|
||||
/// Reply to the control requests whose offloaded jobs ended.
|
||||
fn finish_job_waiters(&mut self, ctx: &egui::Context) {
|
||||
if self.job_waiters.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.session.poll_render();
|
||||
self.session.poll_offload();
|
||||
let waiters = std::mem::take(&mut self.job_waiters);
|
||||
for (w, reply) in waiters {
|
||||
if w.pending(self) {
|
||||
self.job_waiters.push((w, reply));
|
||||
} else {
|
||||
let v = w.finish(self);
|
||||
let _ = reply.send(v);
|
||||
}
|
||||
}
|
||||
if !self.job_waiters.is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
fn issue_screenshots(&mut self, ctx: &egui::Context) {
|
||||
@@ -961,7 +991,9 @@ impl EffectcraftApp {
|
||||
for ev in self.session.drain_events() {
|
||||
match ev {
|
||||
effectcraft_engine::Event::OpenComp(_) => {
|
||||
self.ui.dock.activate(PanelKind::Composition);
|
||||
if !self.ui.locked_tabs.contains(&PanelKind::Composition.id()) {
|
||||
self.ui.dock.activate(PanelKind::Composition);
|
||||
}
|
||||
self.ui.timeline.pps = None;
|
||||
}
|
||||
effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))),
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
//! - layer parameters have a layer popup and a Source / Masks / Effects & Masks popup; mask
|
||||
//! ("Path") parameters list the layer's masks;
|
||||
//! - Curves has its graph editor, Levels its histogram with input/output triangles, Auto
|
||||
//! Levels a histogram.
|
||||
//! Levels a histogram; Lumetri's curves, Colorama's output cycle, Glow's colour map and
|
||||
//! Reshape's correspondence points have visual editors ([`super::fx_editors`]).
|
||||
|
||||
use effectcraft_engine::geom::Mat3;
|
||||
use effectcraft_engine::keyframe::Value;
|
||||
@@ -40,7 +41,7 @@ fn selected_layer(app: &EffectcraftApp) -> Option<Layer> {
|
||||
|
||||
/// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate
|
||||
/// drags are separate undo steps while one drag is one step.
|
||||
fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String {
|
||||
pub(super) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String {
|
||||
let kid = id.with("gesture");
|
||||
let n: u64 = if started {
|
||||
let n = ui.data(|d| d.get_temp::<u64>(kid)).unwrap_or(0) + 1;
|
||||
@@ -536,6 +537,15 @@ fn group_rows(
|
||||
}
|
||||
if open {
|
||||
let sub = fx.sub(&sg.match_id);
|
||||
// A visual editor for the group's hidden parameters (Lumetri curves, …).
|
||||
let eh = super::fx_editors::inline_height(fx.effect, &sub.path, rect.width());
|
||||
if eh > 0.0 {
|
||||
let er = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), eh));
|
||||
*y += eh;
|
||||
if er.max.y >= rect.min.y && er.min.y <= rect.max.y {
|
||||
super::fx_editors::inline_editor(app, ui, p, layer, fx.root, sg, fx.effect, &sub.path, ectx, er, actions);
|
||||
}
|
||||
}
|
||||
group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, &sub, actions);
|
||||
}
|
||||
}
|
||||
@@ -603,8 +613,9 @@ fn roto_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l
|
||||
// Curves / Levels editors
|
||||
|
||||
/// Height of the custom editor shown under an effect's header (0 when none).
|
||||
fn editor_height(effect: &str, width: f32) -> f32 {
|
||||
fn editor_height(effect: &str, g: &PropGroup, width: f32) -> f32 {
|
||||
match effect {
|
||||
super::fx_editors::GLOW | super::fx_editors::RESHAPE => super::fx_editors::header_height(effect, g, width),
|
||||
"ec.color.curves" => 34.0 + curves_size(width) + 26.0,
|
||||
effectcraft_engine::effects::warp_stab::ID => 50.0,
|
||||
effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::EDITOR_HEIGHT,
|
||||
@@ -995,7 +1006,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
item(ui, "Move Down", fi + 1 < fx.len(), "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi + 2}));
|
||||
});
|
||||
if open {
|
||||
let eh = editor_height(effect, body.width());
|
||||
let eh = editor_height(effect, g, body.width());
|
||||
if eh > 0.0 {
|
||||
let er = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), eh));
|
||||
y += eh;
|
||||
@@ -1006,6 +1017,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
effectcraft_engine::effects::camera_tracker::ID => super::camera_tracker_ui::editor(app, ui, &bp, &layer, g, er, &mut actions),
|
||||
effectcraft_engine::effects::roto::ID => roto_editor(app, ui, &bp, &layer, g, er, &mut actions),
|
||||
"ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions),
|
||||
super::fx_editors::GLOW | super::fx_editors::RESHAPE => {
|
||||
super::fx_editors::header_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions)
|
||||
}
|
||||
_ => histogram_only(app, ui, &bp, g, er),
|
||||
}
|
||||
}
|
||||
@@ -1130,10 +1144,14 @@ pub fn viewer_hook(
|
||||
} else if app.ui.viewer.show_layer_controls {
|
||||
// ⊕ controls for the point parameters of the selected effects.
|
||||
let sel = app.session.state.selected_props.clone();
|
||||
for layer in ectx.comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id) && l.is_active_at(ectx.time)) {
|
||||
let selected = app.session.state.selected_layers.clone();
|
||||
for layer in ectx.comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(ectx.time)) {
|
||||
let Some(fx) = layer.effects() else { continue };
|
||||
let m = l2c(ectx, layer);
|
||||
for g in fx.groups().filter(|g| g.enabled && sel.iter().any(|(l, u)| *l == layer.id && *u == g.uid)) {
|
||||
if matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) {
|
||||
super::fx_editors::reshape_overlay(app, ui, painter, map, ectx, layer, g, &m, &mut actions);
|
||||
}
|
||||
let pts: Vec<&Property> = g.props().filter(|p| matches!(p.ui, ParamUi::Point)).collect();
|
||||
let screen: Vec<egui::Pos2> = pts
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,932 @@
|
||||
//! Visual editors for effect parameters stored as hidden or text parameters (Effect Controls):
|
||||
//!
|
||||
//! - Lumetri Color ▸ Curves ▸ RGB Curves: master / red / green / blue curve graphs (channel
|
||||
//! swatches like After Effects'), and ▸ Hue Saturation Curves: Hue vs Sat, Hue vs Hue, Hue vs
|
||||
//! Luma, Luma vs Sat and Sat vs Sat graphs over their colour gradients (y = ½ is neutral);
|
||||
//! - Colorama ▸ Output Cycle: the palette wheel, with draggable colour stops (click the ring to
|
||||
//! add one, Alt-click or drag one off to remove it, select one to pick its colour); editing a
|
||||
//! preset palette turns it into Custom;
|
||||
//! - Glow: the colour map (A & B Colors: a preview strip of the gradient over glow brightness;
|
||||
//! Arbitrary Map: red / green / blue curve graphs and the resulting strip);
|
||||
//! - Reshape: correspondence point list buttons here, and on the Composition viewer the source
|
||||
//! and destination mask outlines with draggable correspondence handles ([`reshape_overlay`]).
|
||||
//!
|
||||
//! Every edit is a `prop.set` engine command (undoable; one drag = one undo step) and every
|
||||
//! handle registers an automation id.
|
||||
|
||||
use effectcraft_engine::effects::{ColoramaPalette, OffsetCurve};
|
||||
use effectcraft_engine::geom::Mat3;
|
||||
use effectcraft_engine::keyframe::Value;
|
||||
use effectcraft_engine::project::{Layer, PropGroup};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use super::effect_controls::gesture_key;
|
||||
use super::fx_widgets as fw;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
type Actions = Vec<(String, serde_json::Value)>;
|
||||
|
||||
pub const LUMETRI: &str = "ec.color.lumetri";
|
||||
pub const COLORAMA: &str = "ec.color.colorama";
|
||||
pub const GLOW: &str = "ec.stylize.glow";
|
||||
pub const RESHAPE: &str = "ec.distort.reshape";
|
||||
|
||||
fn str_value(layer: &Layer, ectx: &EvalCtx, g: &PropGroup, id: &str) -> String {
|
||||
match g.get(id).map(|p| ectx.value(layer, p)) {
|
||||
Some(Value::Str(s)) => s,
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_str(actions: &mut Actions, layer: &Layer, g: &PropGroup, id: &str, v: String, merge: Option<String>) {
|
||||
let Some(p) = g.get(id) else { return };
|
||||
let mut a = json!({"layer": layer.id.0, "prop": p.uid, "value": v});
|
||||
if let Some(m) = merge {
|
||||
a["merge"] = json!(m);
|
||||
}
|
||||
actions.push(("prop.set".into(), a));
|
||||
}
|
||||
|
||||
/// Register automation ids for a curve's control points (`<prefix>.point.<i>`).
|
||||
fn register_points(app: &mut EffectcraftApp, prefix: &str, gr: Rect, pts: &[[f32; 2]]) {
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
let s = pos2(gr.min.x + q[0] * gr.width(), gr.max.y - q[1] * gr.height());
|
||||
app.auto.add(&format!("{prefix}.point.{i}"), Rect::from_center_size(s, vec2(9.0, 9.0)), &format!("Point {}", i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Small coloured tabs (`names[i]`, swatch `cols[i]`); returns the clicked one.
|
||||
fn tabs(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
x0: f32,
|
||||
cy: f32,
|
||||
cur: usize,
|
||||
names: &[&str],
|
||||
cols: &[Color32],
|
||||
id: &str,
|
||||
) -> Option<usize> {
|
||||
let t = app.tokens;
|
||||
let mut x = x0;
|
||||
let mut out = None;
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let w = if cols.is_empty() { 16.0 + name.chars().count() as f32 * 6.2 } else { 20.0 };
|
||||
let r = Rect::from_min_size(pos2(x, cy - 9.0), vec2(w, 18.0));
|
||||
let resp = ui.interact(r, egui::Id::new((id, i)), Sense::click());
|
||||
let sel = i == cur;
|
||||
if sel {
|
||||
p.rect_filled(r, 3.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
p.rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
match cols.get(i) {
|
||||
Some(c) => {
|
||||
p.circle_filled(r.center(), 5.5, *c);
|
||||
if sel {
|
||||
p.circle_stroke(r.center(), 7.0, Stroke::new(1.0, t.text));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
p.text(r.center(), Align2::CENTER_CENTER, *name, Tokens::ui(11.0), if sel { t.text } else { t.text_dim });
|
||||
}
|
||||
}
|
||||
app.auto.add(&format!("{id}.{i}"), r, name);
|
||||
if resp.clicked() {
|
||||
out = Some(i);
|
||||
}
|
||||
resp.on_hover_text(*name);
|
||||
x += w + 4.0;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn reset_link(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str) -> bool {
|
||||
let t = app.tokens;
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::click());
|
||||
p.text(r.center(), Align2::CENTER_CENTER, "Reset", Tokens::ui(11.5), if resp.hovered() { t.hot_text } else { t.text_dim });
|
||||
app.auto.add(id, r, "Reset");
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
fn graph_size(width: f32) -> f32 {
|
||||
(width - 72.0).clamp(120.0, 256.0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Inline editors (inside a parameter group)
|
||||
|
||||
/// Height of the editor shown at the top of group `path` (spec-id path with a trailing `/`)
|
||||
/// of `effect` (0 when none).
|
||||
pub fn inline_height(effect: &str, path: &str, width: f32) -> f32 {
|
||||
match (effect, path) {
|
||||
(LUMETRI, "curves/rgbCurves/") => 30.0 + graph_size(width) + 22.0,
|
||||
(LUMETRI, "curves/hueSaturationCurves/") => 30.0 + graph_size(width) * 0.6 + 34.0,
|
||||
(COLORAMA, "outputCycle/") => 30.0 + wheel_size(width) + 34.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the editor of group `g` (at `path`) in `r`. `root`: the effect's group.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn inline_editor(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
layer: &Layer,
|
||||
root: &PropGroup,
|
||||
g: &PropGroup,
|
||||
effect: &str,
|
||||
path: &str,
|
||||
ectx: &EvalCtx,
|
||||
r: Rect,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
match (effect, path) {
|
||||
(LUMETRI, "curves/rgbCurves/") => rgb_curves(app, ui, p, layer, root, g, ectx, r, actions),
|
||||
(LUMETRI, "curves/hueSaturationCurves/") => huesat_curves(app, ui, p, layer, root, g, ectx, r, actions),
|
||||
(COLORAMA, "outputCycle/") => colorama_wheel(app, ui, p, layer, root, g, ectx, r, actions),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
const RGB_CURVES: [(&str, &str); 4] = [("master", "Master"), ("red", "Red"), ("green", "Green"), ("blue", "Blue")];
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rgb_curves(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
layer: &Layer,
|
||||
root: &PropGroup,
|
||||
g: &PropGroup,
|
||||
ectx: &EvalCtx,
|
||||
r: Rect,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let euid = root.uid;
|
||||
let prefix = format!("effectControls.effect.{euid}.lumetri.rgbCurves");
|
||||
let ch = app.ui.fx_curve_channel.get(&g.uid).copied().unwrap_or(0).min(3);
|
||||
let x0 = r.min.x + 38.0;
|
||||
let cy = r.min.y + 15.0;
|
||||
let cols = [Color32::from_gray(0xe8), Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)];
|
||||
let names: Vec<&str> = RGB_CURVES.iter().map(|c| c.1).collect();
|
||||
if let Some(n) = tabs(app, ui, p, x0, cy, ch, &names, &cols, &format!("{prefix}.channel")) {
|
||||
app.ui.fx_curve_channel.insert(g.uid, n);
|
||||
}
|
||||
let id = RGB_CURVES[ch].0;
|
||||
let cur = fw::CurvePoints::parse(&str_value(layer, ectx, g, id));
|
||||
let size = graph_size(r.width());
|
||||
if reset_link(app, ui, p, Rect::from_min_size(pos2(x0 + size - 44.0, cy - 9.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) {
|
||||
set_str(actions, layer, g, id, fw::CurvePoints::identity().format(), None);
|
||||
}
|
||||
let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size));
|
||||
let gid = egui::Id::new(("lumetri-rgb", euid, ch));
|
||||
let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, false, gid, &t);
|
||||
app.auto.add(&format!("{prefix}.graph"), gr, "RGB Curves");
|
||||
register_points(app, &prefix, gr, &cur.0);
|
||||
let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked());
|
||||
if let Some(np) = edit.changed {
|
||||
set_str(actions, layer, g, id, np.format(), Some(key));
|
||||
}
|
||||
p.text(pos2(gr.min.x, gr.max.y + 11.0), Align2::LEFT_CENTER, "Click adds a point; drag one off to remove", Tokens::ui(10.5), t.text_faint);
|
||||
}
|
||||
|
||||
/// A hue / saturation curve's points (`x,y` in 0..1, y = ½ neutral), any count (empty = flat).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OffsetPoints(pub Vec<[f32; 2]>);
|
||||
|
||||
impl OffsetPoints {
|
||||
pub fn parse(s: &str) -> OffsetPoints {
|
||||
let mut v: Vec<[f32; 2]> = s
|
||||
.split(|c: char| c.is_whitespace() || c == ';')
|
||||
.filter_map(|tk| {
|
||||
let (x, y) = tk.split_once(',')?;
|
||||
let (x, y) = (x.trim().parse::<f32>().ok()?, y.trim().parse::<f32>().ok()?);
|
||||
(x.is_finite() && y.is_finite()).then_some([x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)])
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(|a, b| a[0].total_cmp(&b[0]));
|
||||
OffsetPoints(v)
|
||||
}
|
||||
pub fn format(&self) -> String {
|
||||
let f = |v: f32| {
|
||||
let s = format!("{v:.4}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.').to_string();
|
||||
if s.is_empty() || s == "-0" { "0".into() } else { s }
|
||||
};
|
||||
self.0.iter().map(|q| format!("{},{}", f(q[0]), f(q[1]))).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
pub fn insert(&mut self, x: f32, y: f32) -> usize {
|
||||
let i = self.0.partition_point(|q| q[0] < x);
|
||||
self.0.insert(i, [x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)]);
|
||||
i
|
||||
}
|
||||
/// Move point `i`, keeping the order.
|
||||
pub fn move_point(&mut self, i: usize, x: f32, y: f32) {
|
||||
let n = self.0.len();
|
||||
if i >= n {
|
||||
return;
|
||||
}
|
||||
let lo = if i == 0 { 0.0 } else { self.0[i - 1][0] + 0.002 };
|
||||
let hi = if i + 1 == n { 1.0 } else { self.0[i + 1][0] - 0.002 };
|
||||
self.0[i] = [x.clamp(lo, hi.max(lo)), y.clamp(0.0, 1.0)];
|
||||
}
|
||||
pub fn hit(&self, x: f32, y: f32, r: [f32; 2]) -> Option<usize> {
|
||||
self.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, q)| (i, ((q[0] - x) / r[0]).hypot((q[1] - y) / r[1])))
|
||||
.filter(|(_, d)| *d <= 1.0)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lumetri's hue / saturation curves: parameter id, tab name, x axis (0 hue, 1 luma, 2 sat),
|
||||
/// whether x wraps around.
|
||||
const HUESAT: [(&str, &str, u8, bool); 5] = [
|
||||
("hueVsSat", "Hue vs Sat", 0, true),
|
||||
("hueVsHue", "Hue vs Hue", 0, true),
|
||||
("hueVsLuma", "Hue vs Luma", 0, true),
|
||||
("lumaVsSat", "Luma vs Sat", 1, false),
|
||||
("satVsSat", "Sat vs Sat", 2, false),
|
||||
];
|
||||
|
||||
fn hue_color(h: f32) -> Color32 {
|
||||
let c = egui::ecolor::Hsva::new(h.rem_euclid(1.0), 0.85, 0.9, 1.0);
|
||||
Color32::from(c)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn huesat_curves(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
layer: &Layer,
|
||||
root: &PropGroup,
|
||||
g: &PropGroup,
|
||||
ectx: &EvalCtx,
|
||||
r: Rect,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let euid = root.uid;
|
||||
let prefix = format!("effectControls.effect.{euid}.lumetri.hueSatCurves");
|
||||
let k = app.ui.fx_curve_channel.get(&g.uid).copied().unwrap_or(0).min(4);
|
||||
let x0 = r.min.x + 38.0;
|
||||
let cy = r.min.y + 15.0;
|
||||
let names: Vec<&str> = HUESAT.iter().map(|c| c.1).collect();
|
||||
if let Some(n) = tabs(app, ui, p, x0, cy, k, &names, &[], &format!("{prefix}.curve")) {
|
||||
app.ui.fx_curve_channel.insert(g.uid, n);
|
||||
}
|
||||
let (id, name, axis, periodic) = HUESAT[k];
|
||||
let cur = OffsetPoints::parse(&str_value(layer, ectx, g, id));
|
||||
let size = graph_size(r.width());
|
||||
let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size * 0.6));
|
||||
// Background: the x axis' colours (hue spectrum, grey ramp) as a band, the neutral line.
|
||||
p.rect_filled(gr, 0.0, Color32::from_rgb(0x1c, 0x1c, 0x1c));
|
||||
let n = 48;
|
||||
for i in 0..n {
|
||||
let f = (i as f32 + 0.5) / n as f32;
|
||||
let c = match axis {
|
||||
0 => hue_color(f),
|
||||
1 => Color32::from_gray((f * 255.0) as u8),
|
||||
_ => Color32::from(egui::ecolor::Hsva::new(0.0, f, 0.85, 1.0)),
|
||||
};
|
||||
let x = gr.min.x + gr.width() * i as f32 / n as f32;
|
||||
let band = Rect::from_min_max(pos2(x, gr.max.y - 8.0), pos2(x + gr.width() / n as f32 + 0.5, gr.max.y));
|
||||
p.rect_filled(band, 0.0, c);
|
||||
p.rect_filled(Rect::from_min_max(pos2(band.min.x, gr.min.y), pos2(band.max.x, gr.max.y - 8.0)), 0.0, c.gamma_multiply(0.12));
|
||||
}
|
||||
let to_s = |x: f32, y: f32| pos2(gr.min.x + x * gr.width(), gr.max.y - y * gr.height());
|
||||
p.line_segment([to_s(0.0, 0.5), to_s(1.0, 0.5)], Stroke::new(1.0, Color32::from_white_alpha(60)));
|
||||
p.rect_stroke(gr, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
let curve = OffsetCurve::parse(&cur.format(), periodic);
|
||||
let line: Vec<Pos2> = (0..=96)
|
||||
.map(|i| {
|
||||
let x = i as f32 / 96.0;
|
||||
to_s(x, 0.5 + curve.as_ref().map(|c| c.at(x)).unwrap_or(0.0))
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(line, Stroke::new(1.5, Color32::from_gray(0xe8))));
|
||||
let gid = egui::Id::new(("lumetri-hs", euid, k));
|
||||
let resp = ui.interact(gr.expand(6.0), gid, Sense::click_and_drag());
|
||||
app.auto.add(&format!("{prefix}.graph"), gr, name);
|
||||
let drag_id = gid.with("drag");
|
||||
let dragging: Option<usize> = ui.data(|d| d.get_temp(drag_id));
|
||||
for (i, q) in cur.0.iter().enumerate() {
|
||||
let s = to_s(q[0], q[1]);
|
||||
let hr = Rect::from_center_size(s, vec2(7.0, 7.0));
|
||||
p.circle_filled(s, 3.5, if dragging == Some(i) { Color32::WHITE } else { Color32::from_rgb(0x1c, 0x1c, 0x1c) });
|
||||
p.circle_stroke(s, 3.5, Stroke::new(1.2, Color32::WHITE));
|
||||
app.auto.add(&format!("{prefix}.point.{i}"), hr, &format!("Point {}", i + 1));
|
||||
}
|
||||
let to_g = |s: Pos2| [((s.x - gr.min.x) / gr.width()).clamp(0.0, 1.0), ((gr.max.y - s.y) / gr.height()).clamp(0.0, 1.0)];
|
||||
let hit_r = [7.0 / gr.width(), 7.0 / gr.height()];
|
||||
let mut changed: Option<OffsetPoints> = None;
|
||||
let alt = ui.input(|i| i.modifiers.alt);
|
||||
// One undo step per click or drag.
|
||||
let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked());
|
||||
if resp.drag_started()
|
||||
&& let Some(pt) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
let q = to_g(pt);
|
||||
let i = match cur.hit(q[0], q[1], hit_r) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let mut np = cur.clone();
|
||||
let i = np.insert(q[0], q[1]);
|
||||
changed = Some(np);
|
||||
i
|
||||
}
|
||||
};
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, i));
|
||||
} else if resp.dragged()
|
||||
&& let (Some(i), Some(pt)) = (dragging, resp.interact_pointer_pos())
|
||||
{
|
||||
let q = to_g(pt);
|
||||
let mut np = cur.clone();
|
||||
np.move_point(i, q[0], q[1]);
|
||||
if np != cur {
|
||||
changed = Some(np);
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
if let (Some(i), Some(pt)) = (dragging, resp.interact_pointer_pos())
|
||||
&& !gr.expand(14.0).contains(pt)
|
||||
&& i < cur.0.len()
|
||||
{
|
||||
let mut np = cur.clone();
|
||||
np.0.remove(i);
|
||||
changed = Some(np);
|
||||
}
|
||||
ui.data_mut(|d| d.remove::<usize>(drag_id));
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Some(pt) = resp.interact_pointer_pos()
|
||||
{
|
||||
let q = to_g(pt);
|
||||
let mut np = cur.clone();
|
||||
match cur.hit(q[0], q[1], hit_r) {
|
||||
Some(i) if alt => {
|
||||
np.0.remove(i);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
np.insert(q[0], q[1]);
|
||||
}
|
||||
}
|
||||
if np != cur {
|
||||
changed = Some(np);
|
||||
}
|
||||
}
|
||||
if let Some(np) = changed {
|
||||
set_str(actions, layer, g, id, np.format(), Some(key));
|
||||
}
|
||||
if reset_link(app, ui, p, Rect::from_min_size(pos2(gr.max.x - 44.0, gr.max.y + 4.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) {
|
||||
set_str(actions, layer, g, id, String::new(), None);
|
||||
}
|
||||
p.text(pos2(gr.min.x, gr.max.y + 13.0), Align2::LEFT_CENTER, "Click adds a point (½ height = no change)", Tokens::ui(10.5), t.text_faint);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Colorama output cycle
|
||||
|
||||
fn wheel_size(width: f32) -> f32 {
|
||||
(width - 90.0).clamp(110.0, 200.0)
|
||||
}
|
||||
|
||||
/// Colorama's palette as stops: (position, straight RGBA).
|
||||
pub fn format_palette(stops: &[(f32, [f32; 4])]) -> String {
|
||||
let f = |v: f32| {
|
||||
let s = format!("{v:.4}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.').to_string();
|
||||
if s.is_empty() || s == "-0" { "0".into() } else { s }
|
||||
};
|
||||
stops
|
||||
.iter()
|
||||
.map(|(pos, c)| {
|
||||
let rgb = format!("{},{},{}", f(c[0]), f(c[1]), f(c[2]));
|
||||
if (c[3] - 1.0).abs() > 1e-4 { format!("{}:{rgb},{}", f(*pos), f(c[3])) } else { format!("{}:{rgb}", f(*pos)) }
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn rgba8(c: [f32; 4]) -> Color32 {
|
||||
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
Color32::from_rgb(b(c[0]), b(c[1]), b(c[2]))
|
||||
}
|
||||
|
||||
/// Phase (0..1, clockwise from the top) of a point around `c`.
|
||||
fn phase_at(c: Pos2, q: Pos2) -> f32 {
|
||||
let a = (q.x - c.x).atan2(-(q.y - c.y));
|
||||
(a / std::f32::consts::TAU).rem_euclid(1.0)
|
||||
}
|
||||
|
||||
fn wheel_point(c: Pos2, radius: f32, phase: f32) -> Pos2 {
|
||||
let a = phase * std::f32::consts::TAU;
|
||||
pos2(c.x + radius * a.sin(), c.y - radius * a.cos())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn colorama_wheel(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
layer: &Layer,
|
||||
root: &PropGroup,
|
||||
g: &PropGroup,
|
||||
ectx: &EvalCtx,
|
||||
r: Rect,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let euid = root.uid;
|
||||
let prefix = format!("effectControls.effect.{euid}.colorama");
|
||||
let preset = g.get("usePresetPalette").map(|pr| ectx.value(layer, pr).as_enum()).unwrap_or(0);
|
||||
let custom = preset as usize == effectcraft_engine::effects::COLORAMA_PRESETS.len() - 1;
|
||||
let interp = g.get("interpolatePalette").is_none_or(|pr| ectx.value(layer, pr).as_bool());
|
||||
let pal = if custom { ColoramaPalette::parse(&str_value(layer, ectx, g, "palette")) } else { None }.unwrap_or_else(|| ColoramaPalette::preset(preset));
|
||||
let x0 = r.min.x + 38.0;
|
||||
let label = if custom { "Custom palette" } else { effectcraft_engine::effects::COLORAMA_PRESETS.get(preset as usize).copied().unwrap_or("Preset") };
|
||||
p.text(pos2(x0, r.min.y + 15.0), Align2::LEFT_CENTER, format!("Output Cycle: {label}"), Tokens::ui(11.5), t.text_dim);
|
||||
let size = wheel_size(r.width());
|
||||
let c = pos2(x0 + size / 2.0, r.min.y + 30.0 + size / 2.0);
|
||||
let (outer, inner) = (size / 2.0 - 8.0, size / 2.0 - 26.0);
|
||||
// The ring: palette colours around the wheel.
|
||||
let n = 96;
|
||||
for i in 0..n {
|
||||
let (a0, a1) = (i as f32 / n as f32, (i + 1) as f32 / n as f32);
|
||||
let col = rgba8(pal.eval((a0 + a1) * 0.5, interp));
|
||||
let quad = vec![wheel_point(c, inner, a0), wheel_point(c, outer, a0), wheel_point(c, outer, a1 + 0.002), wheel_point(c, inner, a1 + 0.002)];
|
||||
p.add(egui::Shape::convex_polygon(quad, col, Stroke::NONE));
|
||||
}
|
||||
p.circle_stroke(c, outer, Stroke::new(1.0, Color32::from_black_alpha(160)));
|
||||
p.circle_stroke(c, inner, Stroke::new(1.0, Color32::from_black_alpha(160)));
|
||||
let wid = egui::Id::new(("colorama-wheel", euid));
|
||||
let ring = Rect::from_center_size(c, vec2(outer * 2.0 + 40.0, outer * 2.0 + 40.0));
|
||||
let resp = ui.interact(ring, wid, Sense::click_and_drag());
|
||||
app.auto.add(&format!("{prefix}.wheel"), ring, "Output Cycle");
|
||||
let sel_id = wid.with("sel");
|
||||
let drag_id = wid.with("drag");
|
||||
let mut sel: usize = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0);
|
||||
if sel >= pal.stops.len() {
|
||||
sel = 0;
|
||||
}
|
||||
let dragging: Option<usize> = ui.data(|d| d.get_temp(drag_id));
|
||||
// Stops: triangles on the outside of the ring.
|
||||
let mut stop_rects = vec![];
|
||||
for (i, (ph, col)) in pal.stops.iter().enumerate() {
|
||||
let tip = wheel_point(c, outer + 2.0, *ph);
|
||||
let a = wheel_point(c, outer + 12.0, *ph - 0.018);
|
||||
let b = wheel_point(c, outer + 12.0, *ph + 0.018);
|
||||
let fill = rgba8(*col);
|
||||
p.add(egui::Shape::convex_polygon(
|
||||
vec![tip, a, b],
|
||||
fill,
|
||||
Stroke::new(if i == sel || dragging == Some(i) { 1.6 } else { 1.0 }, if i == sel { Color32::WHITE } else { Color32::from_gray(0x80) }),
|
||||
));
|
||||
let hr = Rect::from_center_size(wheel_point(c, outer + 8.0, *ph), vec2(14.0, 14.0));
|
||||
app.auto.add(&format!("{prefix}.stop.{i}"), hr, &format!("Stop {}", i + 1));
|
||||
stop_rects.push(hr);
|
||||
}
|
||||
let hit = |q: Pos2| -> Option<usize> {
|
||||
stop_rects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, hr)| hr.expand(3.0).contains(q))
|
||||
.min_by(|a, b| a.1.center().distance(q).total_cmp(&b.1.center().distance(q)))
|
||||
.map(|(i, _)| i)
|
||||
};
|
||||
let mut new_stops: Option<Vec<(f32, [f32; 4])>> = None;
|
||||
let alt = ui.input(|i| i.modifiers.alt);
|
||||
// One undo step per click or drag (a colour pick is its own step).
|
||||
let key = gesture_key(ui, wid, resp.drag_started() || resp.clicked() || !resp.dragged());
|
||||
if resp.drag_started()
|
||||
&& let Some(q) = ui.input(|i| i.pointer.press_origin())
|
||||
&& let Some(i) = hit(q)
|
||||
{
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(drag_id, i);
|
||||
d.insert_temp(sel_id, i);
|
||||
});
|
||||
} else if resp.dragged()
|
||||
&& let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos())
|
||||
&& i < pal.stops.len()
|
||||
{
|
||||
let mut st = pal.stops.clone();
|
||||
st[i].0 = phase_at(c, q);
|
||||
new_stops = Some(st);
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
if let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos())
|
||||
&& q.distance(c) > outer + 40.0
|
||||
&& pal.stops.len() > 1
|
||||
&& i < pal.stops.len()
|
||||
{
|
||||
let mut st = pal.stops.clone();
|
||||
st.remove(i);
|
||||
new_stops = Some(st);
|
||||
}
|
||||
ui.data_mut(|d| d.remove::<usize>(drag_id));
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
match hit(q) {
|
||||
Some(i) if alt && pal.stops.len() > 1 => {
|
||||
let mut st = pal.stops.clone();
|
||||
st.remove(i);
|
||||
new_stops = Some(st);
|
||||
}
|
||||
Some(i) => {
|
||||
ui.data_mut(|d| d.insert_temp(sel_id, i));
|
||||
}
|
||||
// A click on the ring adds a stop with the colour there (and selects it).
|
||||
None if (inner - 4.0..=outer + 4.0).contains(&q.distance(c)) => {
|
||||
let ph = phase_at(c, q);
|
||||
let mut st = pal.stops.clone();
|
||||
st.push((ph, pal.eval(ph, interp)));
|
||||
st.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let k = st.iter().position(|s| s.0 == ph).unwrap_or(0);
|
||||
ui.data_mut(|d| d.insert_temp(sel_id, k));
|
||||
new_stops = Some(st);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// The selected stop's colour.
|
||||
let sr = Rect::from_min_size(pos2(x0, c.y + size / 2.0 + 8.0), vec2(30.0, 16.0));
|
||||
if let Some((ph, col)) = pal.stops.get(sel).copied() {
|
||||
let pop = wid.with("color");
|
||||
if widgets::swatch(ui, sr, col, wid.with("swatch"), &t).clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(&format!("{prefix}.color"), sr, "Stop color");
|
||||
p.text(pos2(sr.max.x + 8.0, sr.center().y), Align2::LEFT_CENTER, format!("Stop {} at {:.0}°", sel + 1, ph * 360.0), Tokens::ui(11.0), t.text_dim);
|
||||
let mut rgb = [col[0], col[1], col[2]];
|
||||
if crate::header::color_popup(ui, pop, sr.left_bottom(), &mut rgb) {
|
||||
let mut st = pal.stops.clone();
|
||||
st[sel].1 = [rgb[0], rgb[1], rgb[2], col[3]];
|
||||
new_stops = Some(st);
|
||||
}
|
||||
}
|
||||
p.text(
|
||||
pos2(sr.min.x, sr.max.y + 9.0),
|
||||
Align2::LEFT_CENTER,
|
||||
"Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it",
|
||||
Tokens::ui(10.5),
|
||||
t.text_faint,
|
||||
);
|
||||
if let Some(mut st) = new_stops {
|
||||
st.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
// Editing a preset makes it the custom palette (both in one undo step).
|
||||
if !custom && let Some(pr) = g.get("usePresetPalette") {
|
||||
actions.push((
|
||||
"prop.set".into(),
|
||||
json!({"layer": layer.id.0, "prop": pr.uid, "value": effectcraft_engine::effects::COLORAMA_PRESETS.len() - 1, "merge": key}),
|
||||
));
|
||||
}
|
||||
set_str(actions, layer, g, "palette", format_palette(&st), Some(key));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Header editors (under the effect's title)
|
||||
|
||||
/// Height of the editor under `effect`'s header (`g`: the instance), 0 when none.
|
||||
pub fn header_height(effect: &str, g: &PropGroup, width: f32) -> f32 {
|
||||
match effect {
|
||||
GLOW => match g.get("colors").map(|p| p.value.as_enum()) {
|
||||
Some(2) => 30.0 + graph_size(width) + 40.0,
|
||||
Some(1) => 34.0,
|
||||
_ => 0.0,
|
||||
},
|
||||
RESHAPE => 50.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn header_editor(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
layer: &Layer,
|
||||
g: &PropGroup,
|
||||
effect: &str,
|
||||
ectx: &EvalCtx,
|
||||
r: Rect,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
match effect {
|
||||
GLOW => glow_map(app, ui, p, layer, g, ectx, r, actions),
|
||||
RESHAPE => reshape_buttons(app, ui, p, layer, g, ectx, r, actions),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Glow's Arbitrary Map: "red | green | blue" Curves point lists.
|
||||
pub fn split_map(s: &str) -> [fw::CurvePoints; 3] {
|
||||
let mut parts = s.split('|');
|
||||
std::array::from_fn(|_| parts.next().map(fw::CurvePoints::parse).unwrap_or_else(fw::CurvePoints::identity))
|
||||
}
|
||||
|
||||
pub fn join_map(m: &[fw::CurvePoints; 3]) -> String {
|
||||
m.iter().map(|c| c.format()).collect::<Vec<_>>().join(" | ")
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn glow_map(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, ectx: &EvalCtx, r: Rect, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
let euid = g.uid;
|
||||
let prefix = format!("effectControls.effect.{euid}.glow");
|
||||
let mode = g.get("colors").map(|pr| ectx.value(layer, pr).as_enum()).unwrap_or(0);
|
||||
let x0 = r.min.x + 38.0;
|
||||
let num = |id: &str| g.get(id).map(|pr| ectx.value(layer, pr).as_f64()).unwrap_or(0.0);
|
||||
let looping = g.get("colorLooping").map(|pr| ectx.value(layer, pr).as_enum()).unwrap_or(2);
|
||||
let (loops, phase, mid) = (num("colorLoops") as f32, (num("colorPhase") / 360.0) as f32, (num("abMidpoint") / 100.0) as f32);
|
||||
let map = split_map(&str_value(layer, ectx, g, "arbitraryMap"));
|
||||
let color_at = |l: f32| -> Color32 {
|
||||
let tt = effectcraft_engine::effects::glow_ab_t(l, looping, loops, phase, mid);
|
||||
if mode == 2 {
|
||||
Color32::from_rgb(
|
||||
(map[0].eval(tt).clamp(0.0, 1.0) * 255.0) as u8,
|
||||
(map[1].eval(tt).clamp(0.0, 1.0) * 255.0) as u8,
|
||||
(map[2].eval(tt).clamp(0.0, 1.0) * 255.0) as u8,
|
||||
)
|
||||
} else {
|
||||
let col = |id: &str| match g.get(id).map(|pr| ectx.value(layer, pr)) {
|
||||
Some(Value::Color(c)) => c,
|
||||
_ => [0.0; 4],
|
||||
};
|
||||
let (a, b) = (col("colorA"), col("colorB"));
|
||||
let m = |i: usize| ((a[i] + (b[i] - a[i]) * tt as f64).clamp(0.0, 1.0) * 255.0) as u8;
|
||||
Color32::from_rgb(m(0), m(1), m(2))
|
||||
}
|
||||
};
|
||||
let size = graph_size(r.width());
|
||||
let strip_y = if mode == 2 { r.min.y + 30.0 + size + 12.0 } else { r.min.y + 8.0 };
|
||||
let strip = Rect::from_min_size(pos2(x0, strip_y), vec2(size, 12.0));
|
||||
let n = 64;
|
||||
for i in 0..n {
|
||||
let f = (i as f32 + 0.5) / n as f32;
|
||||
let x = strip.min.x + strip.width() * i as f32 / n as f32;
|
||||
p.rect_filled(Rect::from_min_max(pos2(x, strip.min.y), pos2(x + strip.width() / n as f32 + 0.5, strip.max.y)), 0.0, color_at(f));
|
||||
}
|
||||
p.rect_stroke(strip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
app.auto.add(&format!("{prefix}.colorMap"), strip, "Glow colour map");
|
||||
p.text(pos2(strip.max.x + 8.0, strip.center().y), Align2::LEFT_CENTER, "Colour map", Tokens::ui(10.5), t.text_faint);
|
||||
if mode != 2 {
|
||||
return;
|
||||
}
|
||||
let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(2);
|
||||
let cols = [Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)];
|
||||
if let Some(n) = tabs(app, ui, p, x0, r.min.y + 15.0, ch, &["Red", "Green", "Blue"], &cols, &format!("{prefix}.channel")) {
|
||||
app.ui.fx_curve_channel.insert(euid, n);
|
||||
}
|
||||
if reset_link(app, ui, p, Rect::from_min_size(pos2(x0 + size - 44.0, r.min.y + 6.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) {
|
||||
let mut m = map.clone();
|
||||
m[ch] = fw::CurvePoints::identity();
|
||||
set_str(actions, layer, g, "arbitraryMap", join_map(&m), None);
|
||||
}
|
||||
let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size));
|
||||
let gid = egui::Id::new(("glow-map", euid, ch));
|
||||
let (resp, edit) = fw::curves_graph(ui, gr, &map[ch], ch + 1, false, gid, &t);
|
||||
app.auto.add(&format!("{prefix}.graph"), gr, "Arbitrary Map");
|
||||
register_points(app, &prefix, gr, &map[ch].0);
|
||||
let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked());
|
||||
if let Some(np) = edit.changed {
|
||||
let mut m = map.clone();
|
||||
m[ch] = np;
|
||||
set_str(actions, layer, g, "arbitraryMap", join_map(&m), Some(key));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Reshape
|
||||
|
||||
/// Reshape's correspondence pairs (source, destination outline fractions), in stored order.
|
||||
pub fn parse_pairs(s: &str) -> Vec<(f64, f64)> {
|
||||
s.split_whitespace()
|
||||
.filter_map(|tk| {
|
||||
let (a, b) = tk.split_once(',')?;
|
||||
Some((a.trim().parse::<f64>().ok()?.rem_euclid(1.0), b.trim().parse::<f64>().ok()?.rem_euclid(1.0)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn format_pairs(v: &[(f64, f64)]) -> String {
|
||||
let f = |x: f64| {
|
||||
let s = format!("{x:.4}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.').to_string();
|
||||
if s.is_empty() { "0".into() } else { s }
|
||||
};
|
||||
v.iter().map(|(a, b)| format!("{},{}", f(*a), f(*b))).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// A new pair in the widest gap between the destination fractions (the source fraction
|
||||
/// interpolated between its neighbours'), or (0, 0) for the first.
|
||||
pub fn add_pair(v: &[(f64, f64)]) -> (f64, f64) {
|
||||
if v.is_empty() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let mut s: Vec<(f64, f64)> = v.to_vec();
|
||||
s.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let n = s.len();
|
||||
let (mut best, mut gap) = (0, -1.0);
|
||||
for i in 0..n {
|
||||
let g = (s[(i + 1) % n].1 - s[i].1).rem_euclid(1.0);
|
||||
let g = if n == 1 { 1.0 } else { g };
|
||||
if g > gap {
|
||||
gap = g;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
let (a, b) = (s[best], s[(best + 1) % n]);
|
||||
let ds = if n == 1 { 1.0 } else { (b.0 - a.0).rem_euclid(1.0) };
|
||||
((a.0 + ds * 0.5).rem_euclid(1.0), (a.1 + gap * 0.5).rem_euclid(1.0))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn reshape_buttons(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
layer: &Layer,
|
||||
g: &PropGroup,
|
||||
ectx: &EvalCtx,
|
||||
r: Rect,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let euid = g.uid;
|
||||
let pairs = parse_pairs(&str_value(layer, ectx, g, "correspondencePoints"));
|
||||
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
|
||||
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(96.0, 22.0));
|
||||
let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(56.0, 22.0));
|
||||
if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::new(("reshape-add", euid))).clicked() {
|
||||
let mut v = pairs.clone();
|
||||
v.push(add_pair(&pairs));
|
||||
set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None);
|
||||
}
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.reshape.add"), b1, "Add Point");
|
||||
if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::new(("reshape-remove", euid))).clicked() && !pairs.is_empty() {
|
||||
let mut v = pairs.clone();
|
||||
v.pop();
|
||||
set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None);
|
||||
}
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.reshape.remove"), b2, "Remove Point");
|
||||
if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::new(("reshape-clear", euid))).clicked() && !pairs.is_empty() {
|
||||
set_str(actions, layer, g, "correspondencePoints", String::new(), None);
|
||||
}
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.reshape.clear"), b3, "Clear");
|
||||
let status = if pairs.is_empty() {
|
||||
"Correspondence: automatic (select the effect to edit points on the viewer)".to_string()
|
||||
} else {
|
||||
format!("{} correspondence point(s): drag them along the mask outlines in the viewer", pairs.len())
|
||||
};
|
||||
p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim);
|
||||
}
|
||||
|
||||
/// Arc-length fraction (0..1) of the point of a polyline nearest to `q`.
|
||||
pub fn nearest_fraction(pts: &[[f64; 2]], closed: bool, q: [f64; 2]) -> f64 {
|
||||
let n = pts.len();
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let total = effectcraft_engine::effects::util::poly_length(pts, closed).max(1e-9);
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
let (mut best, mut best_s, mut acc) = (f64::INFINITY, 0.0, 0.0);
|
||||
for i in 0..segs {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l2 = dx * dx + dy * dy;
|
||||
let l = l2.sqrt();
|
||||
let tt = if l2 > 0.0 { (((q[0] - a[0]) * dx + (q[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let d = (a[0] + dx * tt - q[0]).hypot(a[1] + dy * tt - q[1]);
|
||||
if d < best {
|
||||
best = d;
|
||||
best_s = acc + l * tt;
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
(best_s / total).rem_euclid(1.0)
|
||||
}
|
||||
|
||||
/// The Composition viewer's Reshape controls while the effect is selected: the source (yellow)
|
||||
/// and destination (cyan) mask outlines and each correspondence pair as a line between its two
|
||||
/// handles; dragging a handle slides it along its outline (one undo step per drag).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn reshape_overlay(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
painter: &egui::Painter,
|
||||
map: &super::viewer::ViewerMap,
|
||||
ectx: &EvalCtx,
|
||||
layer: &Layer,
|
||||
g: &PropGroup,
|
||||
m: &Mat3,
|
||||
actions: &mut Actions,
|
||||
) {
|
||||
let shapes = effectcraft_engine::render::masks::shapes(ectx, layer);
|
||||
let mask = |id: &str| -> Option<(Vec<[f64; 2]>, bool)> {
|
||||
let i = g.get(id).map(|pr| ectx.value(layer, pr).as_enum()).unwrap_or(0) as usize;
|
||||
let s = shapes.get(i.checked_sub(1)?)?;
|
||||
(s.points.len() >= 2).then(|| (s.points.clone(), s.closed))
|
||||
};
|
||||
let (Some(src), Some(dst)) = (mask("sourceMask"), mask("destinationMask")) else { return };
|
||||
let to_screen = |q: [f64; 2]| map.to_screen(fw::layer_to_comp(m, q));
|
||||
let outline = |pts: &[[f64; 2]], closed: bool, col: Color32| {
|
||||
let mut v: Vec<Pos2> = pts.iter().map(|q| to_screen(*q)).collect();
|
||||
if closed && let Some(f) = v.first().copied() {
|
||||
v.push(f);
|
||||
}
|
||||
painter.add(egui::Shape::dashed_line(&v, Stroke::new(1.0, col), 5.0, 3.0));
|
||||
};
|
||||
let (ys, cs) = (Color32::from_rgb(0xff, 0xd8, 0x40), Color32::from_rgb(0x40, 0xd8, 0xff));
|
||||
outline(&src.0, src.1, ys);
|
||||
outline(&dst.0, dst.1, cs);
|
||||
let at = |pts: &[[f64; 2]], closed: bool, f: f64| {
|
||||
let len = effectcraft_engine::effects::util::poly_length(pts, closed);
|
||||
effectcraft_engine::effects::util::poly_point_at(pts, closed, f * len).0
|
||||
};
|
||||
let raw = match g.get("correspondencePoints").map(|pr| ectx.value(layer, pr)) {
|
||||
Some(Value::Str(s)) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let pairs = parse_pairs(&raw);
|
||||
let ctx = ui.ctx().clone();
|
||||
for (i, (fs, fd)) in pairs.iter().enumerate() {
|
||||
let (ps, pd) = (to_screen(at(&src.0, src.1, *fs)), to_screen(at(&dst.0, dst.1, *fd)));
|
||||
painter.line_segment([ps, pd], Stroke::new(1.0, Color32::from_white_alpha(150)));
|
||||
for (end, s, col) in [(0usize, ps, ys), (1, pd, cs)] {
|
||||
let hr = Rect::from_center_size(s, vec2(12.0, 12.0));
|
||||
let id = egui::Id::new(("reshape-pt", g.uid, i, end));
|
||||
let resp = ui.interact(hr, id, Sense::drag());
|
||||
let hot = resp.hovered() || resp.dragged();
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(7.0, 7.0)), 0.0, if hot { Color32::WHITE } else { col });
|
||||
painter.rect_stroke(Rect::from_center_size(s, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside);
|
||||
let which = if end == 0 { "source" } else { "dest" };
|
||||
app.auto.add(&format!("viewer.reshape.{}.{which}.{i}", g.uid), hr, &format!("Correspondence point {} ({which})", i + 1));
|
||||
if hot {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Move);
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
&& let Some(lp) = fw::comp_to_layer(m, map.to_comp(pos))
|
||||
{
|
||||
let mut v = pairs.clone();
|
||||
if end == 0 {
|
||||
v[i].0 = nearest_fraction(&src.0, src.1, lp);
|
||||
} else {
|
||||
v[i].1 = nearest_fraction(&dst.0, dst.1, lp);
|
||||
}
|
||||
let key = gesture_key(ui, id, resp.drag_started());
|
||||
if let Some(pr) = g.get("correspondencePoints") {
|
||||
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": format_pairs(&v), "merge": key})));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offset_points_and_palettes_round_trip() {
|
||||
let p = OffsetPoints::parse("0.5,0.75 0.1,0.5");
|
||||
assert_eq!(p.0, vec![[0.1, 0.5], [0.5, 0.75]]);
|
||||
assert_eq!(OffsetPoints::parse(&p.format()), p);
|
||||
assert!(OffsetPoints::parse("").0.is_empty());
|
||||
let stops = vec![(0.0, [1.0, 0.0, 0.0, 1.0]), (0.5, [0.0, 0.25, 1.0, 0.5])];
|
||||
let s = format_palette(&stops);
|
||||
assert_eq!(s, "0:1,0,0 0.5:0,0.25,1,0.5");
|
||||
assert_eq!(ColoramaPalette::parse(&s).unwrap().stops, stops);
|
||||
let m = split_map("0,0 1,0.5 | | 0,1 1,0");
|
||||
assert_eq!(m[1], fw::CurvePoints::identity());
|
||||
assert_eq!(split_map(&join_map(&m)), m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_pairs_and_outline_fractions() {
|
||||
assert_eq!(parse_pairs("0.25,0 1.5,0.5"), vec![(0.25, 0.0), (0.5, 0.5)]);
|
||||
assert_eq!(format_pairs(&[(0.25, 0.0), (0.5, 0.125)]), "0.25,0 0.5,0.125");
|
||||
assert_eq!(add_pair(&[]), (0.0, 0.0));
|
||||
assert_eq!(add_pair(&[(0.1, 0.0)]), (0.6, 0.5));
|
||||
let (s, d) = add_pair(&[(0.0, 0.0), (0.2, 0.25)]);
|
||||
assert!((d - 0.625).abs() < 1e-9 && (s - 0.6).abs() < 1e-9, "{s} {d}");
|
||||
// A 10×10 square (closed): the point nearest (10, 5) is 3/8 of the way round.
|
||||
let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
|
||||
assert!((nearest_fraction(&sq, true, [12.0, 5.0]) - 0.375).abs() < 1e-9);
|
||||
assert!(nearest_fraction(&sq, true, [-1.0, -1.0]).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -65,13 +65,13 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
p.rect_filled(rect, 0.0, t.panel_bg);
|
||||
if !mb::available() {
|
||||
p.text(rect.center(), Align2::CENTER_CENTER, "The Media Browser needs the desktop app (use File ▸ Import here)", Tokens::ui(12.0), t.text_faint);
|
||||
let Some(b) = app.session.media_browser() else {
|
||||
p.text(rect.center(), Align2::CENTER_CENTER, "The Media Browser is not available here (use File ▸ Import)", Tokens::ui(12.0), t.text_faint);
|
||||
app.auto.add("mediaBrowser.unavailable", rect, "Media Browser unavailable");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let st = app.session.state.media_browser.clone();
|
||||
let dir = st.folder.clone().unwrap_or_else(mb::home);
|
||||
let dir = st.folder.clone().unwrap_or_else(|| b.home());
|
||||
// Listing cached per folder for a second.
|
||||
let lid = egui::Id::new(("mb-list", dir.clone(), st.importable_only));
|
||||
let now = ctx.input(|i| i.time);
|
||||
@@ -79,7 +79,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let entries = match listing {
|
||||
Some((at, l)) if now - at < 1.0 => l,
|
||||
_ => {
|
||||
let l = mb::list(&dir, st.importable_only);
|
||||
let l = b.list(&dir, st.importable_only);
|
||||
ctx.data_mut(|d| d.insert_temp(lid, (now, l.clone())));
|
||||
l
|
||||
}
|
||||
@@ -104,6 +104,20 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if only != st.importable_only {
|
||||
kit::exec(app, "mediaBrowser.go", json!({"path": dir, "importableOnly": only}));
|
||||
}
|
||||
// The browser's own actions (web: Open Folder…, Add Files…), right-aligned.
|
||||
let mut ax = rect.max.x - 8.0;
|
||||
for (id, label) in b.actions().iter().rev() {
|
||||
let w = 14.0 + label.chars().count() as f32 * 6.4;
|
||||
ax -= w;
|
||||
if ax < x + 96.0 {
|
||||
break;
|
||||
}
|
||||
if kit::button(app, ui, Rect::from_min_size(pos2(ax, y), vec2(w, 20.0)), &format!("mediaBrowser.action.{id}"), label, false) {
|
||||
kit::exec(app, "mediaBrowser.action", json!({"action": id}));
|
||||
ctx.data_mut(|d| d.remove::<(f64, Result<Vec<mb::Entry>, String>)>(lid));
|
||||
}
|
||||
ax -= 6.0;
|
||||
}
|
||||
// The path on its own row.
|
||||
let _ = x;
|
||||
let py = y + 24.0;
|
||||
@@ -114,7 +128,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
p.line_segment([side.right_top(), side.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
p.text(pos2(side.min.x + 10.0, side.min.y + 10.0), Align2::LEFT_CENTER, "Favorites", Tokens::semibold(11.5), t.text_dim);
|
||||
let mut fy = side.min.y + 22.0;
|
||||
let mut places: Vec<(String, String)> = vec![("Home".into(), mb::home())];
|
||||
let mut places: Vec<(String, String)> = b.places();
|
||||
places.extend(
|
||||
st.favorites.iter().map(|f| (std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()), f.clone())),
|
||||
);
|
||||
|
||||
@@ -21,7 +21,16 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let y = rect.min.y + 12.0;
|
||||
// The panel scrolls when it is shorter than its controls (the default workspace shows just
|
||||
// the transport row, as in After Effects).
|
||||
let scroll_id = egui::Id::new("preview-scroll");
|
||||
let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0));
|
||||
if ui.rect_contains_pointer(rect) {
|
||||
scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0);
|
||||
}
|
||||
let panel_clip = ui.clip_rect();
|
||||
ui.set_clip_rect(rect.intersect(panel_clip));
|
||||
let y = rect.min.y + 12.0 - scroll;
|
||||
let bw = 30.0;
|
||||
let total = bw * 5.0 + 16.0;
|
||||
let mut x = rect.center().x - total / 2.0;
|
||||
@@ -223,6 +232,18 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let status = if app.playback.playing && app.playback.caching { " — caching before playback" } else { "" };
|
||||
p.text(pos2(bar.min.x, bar.max.y + 12.0), Align2::LEFT_CENTER, format!("{cached} / {total} frames cached{status}"), Tokens::ui(11.0), t.text_faint);
|
||||
}
|
||||
ui.set_clip_rect(panel_clip);
|
||||
let content = yy + 30.0 - (rect.min.y - scroll);
|
||||
let max = (content - rect.height()).max(0.0);
|
||||
scroll = scroll.min(max);
|
||||
ctx.data_mut(|d| d.insert_temp(scroll_id, scroll));
|
||||
if max > 0.0 {
|
||||
// A thin scroll indicator on the right edge.
|
||||
let h = (rect.height() * rect.height() / content).max(12.0);
|
||||
let top = rect.min.y + (rect.height() - h) * (scroll / max);
|
||||
p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, top), vec2(3.0, h)), 1.5, t.text_faint.gamma_multiply(0.6));
|
||||
app.auto.add("preview.scroll", Rect::from_min_size(pos2(rect.max.x - 6.0, rect.min.y), vec2(6.0, rect.height())), "Preview scroll");
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_rate(r: f64) -> String {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod expr_editor;
|
||||
pub mod flowchart;
|
||||
pub mod footage_panel;
|
||||
pub mod forms;
|
||||
pub mod fx_editors;
|
||||
pub mod fx_widgets;
|
||||
pub mod graph;
|
||||
pub mod graph_tools;
|
||||
|
||||
@@ -533,6 +533,10 @@ pub struct UiState {
|
||||
/// The panel maximized to fill the dock area (`~`), if any.
|
||||
#[serde(default)]
|
||||
pub maximized: Option<PanelKind>,
|
||||
/// Locked Composition / Timeline tabs (panel ids): opening another composition does not
|
||||
/// bring a locked panel forward (the tab's lock icon, as After Effects' viewer lock).
|
||||
#[serde(default)]
|
||||
pub locked_tabs: BTreeSet<String>,
|
||||
pub focused: PanelKind,
|
||||
pub viewer: ViewerState,
|
||||
pub timeline: TimelineState,
|
||||
@@ -634,6 +638,7 @@ impl Default for UiState {
|
||||
floating: vec![],
|
||||
saved_floating: Default::default(),
|
||||
maximized: None,
|
||||
locked_tabs: BTreeSet::new(),
|
||||
focused: PanelKind::Composition,
|
||||
viewer: ViewerState::default(),
|
||||
timeline: TimelineState::default(),
|
||||
|
||||
@@ -154,8 +154,27 @@ pub fn dropdown(ui: &mut Ui, rect: Rect, text: &str, t: &Tokens, id: egui::Id) -
|
||||
let resp = ui.interact(rect, id, Sense::click());
|
||||
ui.painter().rect_filled(rect, 3.0, if resp.hovered() { t.hover } else { t.field_bg });
|
||||
ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text);
|
||||
// Text that doesn't fit before the arrow is elided (narrow panels).
|
||||
let room = rect.width() - 7.0 - 18.0;
|
||||
let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), Tokens::ui(11.5), t.text).size().x <= room;
|
||||
let shown = if fits(text) {
|
||||
text.to_string()
|
||||
} else {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut n = chars.len();
|
||||
loop {
|
||||
n = n.saturating_sub(1);
|
||||
let s: String = chars[..n].iter().collect::<String>().trim_end().to_string() + "…";
|
||||
if n == 0 || fits(&s) {
|
||||
break s;
|
||||
}
|
||||
}
|
||||
};
|
||||
ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(11.5), t.text);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 9.0, rect.center().y), vec2(8.0, 8.0)), Icon::ChevronDown, t.text_dim);
|
||||
if text.chars().count() > 0 && !fits(text) {
|
||||
return resp.on_hover_text(text.to_string());
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Headless checks (egui_kittest) of the visual editors for hidden / text effect parameters
|
||||
//! (M13.10): Lumetri's RGB and hue/saturation curves, Colorama's output cycle wheel, Glow's
|
||||
//! colour map and Reshape's correspondence points (Effect Controls buttons and viewer handles).
|
||||
//! Each edit goes through `prop.set` and undoes in one step.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
use effectcraft_engine::project::{LayerId, Node, PropGroup};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui::{Event, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.show_panel(PanelKind::EffectControls);
|
||||
app.toggle_maximize(PanelKind::EffectControls);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1400.0)).build_eframe(|_| app);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// A comp with one 320×180 solid carrying `effect`; returns the session, layer and effect uid.
|
||||
fn with_effect(effect: &str) -> (Session, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Fx", "width": 320, "height": 180, "frameRate": 24, "duration": 4})).unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"name": "S", "color": "#6080a0"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let fx = s.execute("effect.apply", json!({"layers": [l], "effect": effect})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
s.state.selected_layers = vec![LayerId(l)];
|
||||
(s, l, fx)
|
||||
}
|
||||
|
||||
fn group(h: &Harness<'_, EffectcraftApp>, layer: u64, fx: u64) -> PropGroup {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find_group(fx).unwrap().clone()
|
||||
}
|
||||
|
||||
/// A parameter's value by its spec-id path inside the effect (`curves/rgbCurves/master`).
|
||||
fn value(h: &Harness<'_, EffectcraftApp>, layer: u64, fx: u64, path: &str) -> KV {
|
||||
let g = group(h, layer, fx);
|
||||
let mut cur = &g;
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
for p in &parts[..parts.len() - 1] {
|
||||
cur = cur.sub(p).unwrap_or_else(|| panic!("no group {p}"));
|
||||
}
|
||||
cur.get(parts[parts.len() - 1]).unwrap().value.clone()
|
||||
}
|
||||
|
||||
fn s(v: KV) -> String {
|
||||
match v {
|
||||
KV::Str(s) => s,
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close every parameter group of the effect except those named (and their parents).
|
||||
fn only_groups(h: &mut Harness<'_, EffectcraftApp>, layer: u64, fx: u64, keep: &[&str]) {
|
||||
fn walk(g: &PropGroup, keep: &[&str], out: &mut Vec<u64>) {
|
||||
for c in &g.children {
|
||||
if let Node::Group(sg) = c {
|
||||
if !keep.contains(&sg.match_id.as_str()) {
|
||||
out.push(sg.uid);
|
||||
}
|
||||
walk(sg, keep, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let g = group(h, layer, fx);
|
||||
let mut closed = vec![];
|
||||
walk(&g, keep, &mut closed);
|
||||
h.state_mut().ui.fx_closed.extend(closed);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
|
||||
let e = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == id).cloned().unwrap_or_else(|| {
|
||||
let near: Vec<String> = h
|
||||
.state()
|
||||
.auto
|
||||
.elements
|
||||
.iter()
|
||||
.map(|e| e.id.clone())
|
||||
.filter(|i| i.starts_with("effectControls.effect") || i.starts_with("viewer.reshape"))
|
||||
.collect();
|
||||
panic!("no element {id}; have {near:?}")
|
||||
});
|
||||
egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
fn at(r: egui::Rect, fx: f32, fy: f32) -> Pos2 {
|
||||
pos2(r.min.x + r.width() * fx, r.min.y + r.height() * fy)
|
||||
}
|
||||
|
||||
fn click_n(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, n: u32) {
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.step();
|
||||
// (all in one frame: the harness' frame time is longer than a double-click's)
|
||||
for _ in 0..n {
|
||||
h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
}
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
let p = rect(h, id).center();
|
||||
click_n(h, p, 1);
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
for k in 1..=6 {
|
||||
let t = k as f32 / 6.0;
|
||||
h.event(Event::PointerMoved(from + (to - from) * t));
|
||||
h.step();
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn undo(h: &mut Harness<'_, EffectcraftApp>) {
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lumetri_rgb_and_hue_saturation_curve_editors() {
|
||||
let (s0, l, fx) = with_effect("Lumetri Color");
|
||||
let mut h = harness(s0);
|
||||
only_groups(&mut h, l, fx, &["curves", "rgbCurves", "hueSaturationCurves"]);
|
||||
let pre = format!("effectControls.effect.{fx}.lumetri.rgbCurves");
|
||||
for k in 0..4 {
|
||||
rect(&h, &format!("{pre}.channel.{k}"));
|
||||
}
|
||||
rect(&h, &format!("{pre}.point.0"));
|
||||
// Red channel: a click in the graph adds a point to the red curve only.
|
||||
click_id(&mut h, &format!("{pre}.channel.1"));
|
||||
let gr = rect(&h, &format!("{pre}.graph"));
|
||||
click_n(&mut h, at(gr, 0.5, 0.25), 1);
|
||||
let red = s(value(&h, l, fx, "curves/rgbCurves/red"));
|
||||
assert_eq!(red.split_whitespace().count(), 3, "{red}");
|
||||
assert_eq!(s(value(&h, l, fx, "curves/rgbCurves/master")), "0,0 1,1");
|
||||
rect(&h, &format!("{pre}.point.2"));
|
||||
undo(&mut h);
|
||||
assert_eq!(s(value(&h, l, fx, "curves/rgbCurves/red")), "0,0 1,1");
|
||||
// Hue vs Hue: pick the tab, add a point, drag it: one undo step for the drag.
|
||||
let hs = format!("effectControls.effect.{fx}.lumetri.hueSatCurves");
|
||||
click_id(&mut h, &format!("{hs}.curve.1"));
|
||||
let gr = rect(&h, &format!("{hs}.graph"));
|
||||
click_n(&mut h, at(gr, 0.3, 0.5), 1);
|
||||
let v = s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsHue"));
|
||||
assert_eq!(v.split_whitespace().count(), 1, "{v}");
|
||||
assert_eq!(s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsSat")), "");
|
||||
let p0 = rect(&h, &format!("{hs}.point.0")).center();
|
||||
let undo_len = h.state().session.history.undo.len();
|
||||
drag(&mut h, p0, p0 + egui::vec2(0.0, -gr.height() * 0.25));
|
||||
let v = s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsHue"));
|
||||
let y: f32 = v.split(',').nth(1).unwrap().parse().unwrap();
|
||||
assert!(y > 0.7, "{v}");
|
||||
assert_eq!(h.state().session.history.undo.len(), undo_len + 1, "one undo step per drag");
|
||||
// Reset empties the curve.
|
||||
click_id(&mut h, &format!("{hs}.reset"));
|
||||
assert_eq!(s(value(&h, l, fx, "curves/hueSaturationCurves/hueVsHue")), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorama_output_cycle_wheel() {
|
||||
let (s0, l, fx) = with_effect("Colorama");
|
||||
let mut h = harness(s0);
|
||||
only_groups(&mut h, l, fx, &["outputCycle"]);
|
||||
let pre = format!("effectControls.effect.{fx}.colorama");
|
||||
// The Hue Cycle preset has six stops.
|
||||
for i in 0..6 {
|
||||
rect(&h, &format!("{pre}.stop.{i}"));
|
||||
}
|
||||
// Clicking the ring adds a stop: the palette becomes Custom with seven stops.
|
||||
let w = rect(&h, &format!("{pre}.wheel"));
|
||||
let mid = w.width() / 2.0 - 20.0 - 9.0;
|
||||
let ring = pos2(w.center().x + mid * std::f32::consts::FRAC_1_SQRT_2, w.center().y + mid * std::f32::consts::FRAC_1_SQRT_2);
|
||||
click_n(&mut h, ring, 1);
|
||||
assert_eq!(value(&h, l, fx, "outputCycle/usePresetPalette"), KV::Enum(8));
|
||||
let pal = s(value(&h, l, fx, "outputCycle/palette"));
|
||||
assert_eq!(pal.split_whitespace().count(), 7, "{pal}");
|
||||
rect(&h, &format!("{pre}.stop.6"));
|
||||
// Dragging a stop moves its position.
|
||||
let st = rect(&h, &format!("{pre}.stop.0")).center();
|
||||
drag(&mut h, st, pos2(w.max.x - 6.0, w.center().y));
|
||||
let pal2 = s(value(&h, l, fx, "outputCycle/palette"));
|
||||
assert_ne!(pal, pal2);
|
||||
assert!(pal2.split_whitespace().any(|t| t.starts_with("0.25") || t.starts_with("0.24") || t.starts_with("0.26")), "{pal2}");
|
||||
rect(&h, &format!("{pre}.color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glow_arbitrary_map_editor() {
|
||||
let (mut s0, l, fx) = with_effect("Glow");
|
||||
let colors = s0.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(fx).unwrap().get("colors").unwrap().uid;
|
||||
s0.execute("prop.set", json!({"layer": l, "prop": colors, "value": 1})).unwrap();
|
||||
let mut h = harness(s0);
|
||||
let pre = format!("effectControls.effect.{fx}.glow");
|
||||
rect(&h, &format!("{pre}.colorMap"));
|
||||
assert!(!h.state().auto.elements.iter().any(|e| e.id == format!("{pre}.graph")), "A & B: just the strip");
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": l, "prop": colors, "value": 2})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_id(&mut h, &format!("{pre}.channel.2"));
|
||||
let gr = rect(&h, &format!("{pre}.graph"));
|
||||
click_n(&mut h, at(gr, 0.5, 0.9), 1);
|
||||
let m = s(value(&h, l, fx, "arbitraryMap"));
|
||||
let parts: Vec<&str> = m.split('|').map(str::trim).collect();
|
||||
assert_eq!(parts.len(), 3, "{m}");
|
||||
assert_eq!(parts[0], "0,0 1,1");
|
||||
assert_eq!(parts[2].split_whitespace().count(), 3, "{m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_correspondence_points_on_the_viewer() {
|
||||
let (mut s0, l, fx) = with_effect("Reshape");
|
||||
let sq = |x0: f64, y0: f64, d: f64| json!([[x0, y0], [x0 + d, y0], [x0 + d, y0 + d], [x0, y0 + d]]);
|
||||
s0.execute("mask.new", json!({"layer": l, "vertices": sq(60.0, 40.0, 60.0), "closed": true})).unwrap();
|
||||
s0.execute("mask.new", json!({"layer": l, "vertices": sq(180.0, 60.0, 80.0), "closed": true})).unwrap();
|
||||
let g = s0.active_comp().unwrap().layer(LayerId(l)).unwrap().props.find_group(fx).unwrap().clone();
|
||||
s0.execute("prop.set", json!({"layer": l, "prop": g.get("sourceMask").unwrap().uid, "value": 1})).unwrap();
|
||||
s0.execute("prop.set", json!({"layer": l, "prop": g.get("destinationMask").unwrap().uid, "value": 2})).unwrap();
|
||||
let mut h = harness(s0);
|
||||
let pre = format!("effectControls.effect.{fx}.reshape");
|
||||
click_id(&mut h, &format!("{pre}.add"));
|
||||
assert_eq!(s(value(&h, l, fx, "correspondencePoints")), "0,0");
|
||||
click_id(&mut h, &format!("{pre}.add"));
|
||||
assert_eq!(s(value(&h, l, fx, "correspondencePoints")).split_whitespace().count(), 2);
|
||||
click_id(&mut h, &format!("{pre}.remove"));
|
||||
assert_eq!(s(value(&h, l, fx, "correspondencePoints")), "0,0");
|
||||
// With the effect selected, the viewer shows the handles: drag the destination one along
|
||||
// its outline.
|
||||
h.state_mut().toggle_maximize(PanelKind::EffectControls);
|
||||
h.state_mut().session.state.selected_props = vec![(LayerId(l), fx)];
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
let d0 = rect(&h, &format!("viewer.reshape.{fx}.dest.0"));
|
||||
rect(&h, &format!("viewer.reshape.{fx}.source.0"));
|
||||
let undo_len = h.state().session.history.undo.len();
|
||||
drag(&mut h, d0.center(), d0.center() + egui::vec2(60.0, 0.0));
|
||||
let v = s(value(&h, l, fx, "correspondencePoints"));
|
||||
let d: f64 = v.split(',').nth(1).unwrap().parse().unwrap();
|
||||
assert!(d > 0.02 && d < 0.3, "moved along the top edge: {v}");
|
||||
assert_eq!(h.state().session.history.undo.len(), undo_len + 1);
|
||||
}
|
||||
|
||||
/// UI fixes of M13.10: the Composition / Timeline tabs' close button, swatch and lock; the
|
||||
/// Preview panel scrolls in the default workspace (where it shows just the transport).
|
||||
#[test]
|
||||
fn composition_tabs_and_scrolling_preview() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(4);
|
||||
rect(&h, "panel.tab.Timeline.close");
|
||||
rect(&h, "preview.scroll");
|
||||
click_id(&mut h, "panel.tab.Composition.lock");
|
||||
assert!(h.state().ui.locked_tabs.contains("Composition"));
|
||||
click_id(&mut h, "panel.tab.Composition.lock");
|
||||
assert!(h.state().ui.locked_tabs.is_empty());
|
||||
click_id(&mut h, "panel.tab.Timeline.close");
|
||||
assert!(!h.state().ui.dock.contains(PanelKind::Timeline));
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Headless checks for M13.10 (web app depth), run natively with stand-ins for the browser's
|
||||
//! workers:
|
||||
//!
|
||||
//! - viewer frames go to a [`RemoteFrames`] renderer (the browser's frame worker, here an
|
||||
//! in-process [`FrameServer`] fed through JSON with project diffs) instead of rendering on the
|
||||
//! UI thread, the viewer's frame first, and come back identical;
|
||||
//! - a control-channel `wait: true` command whose job is offloaded replies only when the job
|
||||
//! ends, while the UI keeps running.
|
||||
|
||||
use std::sync::mpsc::TryRecvError;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::offload::{Inbox, Offload, Post, WorkerRequest};
|
||||
use effectcraft_engine::remote::{FrameMsg, FrameReply, FrameServer, Mirror};
|
||||
use effectcraft_engine::render::RenderOpts;
|
||||
use effectcraft_ui_egui::frames::{FrameImage, FrameKey, Frames, RemoteDone, RemoteFrame, RemoteFrames, RemoteJob, RenderSource};
|
||||
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// A frame worker in the same process: messages go through JSON like `postMessage`; rendered
|
||||
/// frames are held until [`FakeWorker::deliver`] (a busy worker) or dropped by
|
||||
/// [`FakeWorker::lose`].
|
||||
struct FakeWorker {
|
||||
slots: usize,
|
||||
state: Mutex<(Mirror, FrameServer, Vec<(RemoteDone, RemoteFrame)>, u64)>,
|
||||
/// Patch / full-project syncs sent.
|
||||
syncs: Mutex<Vec<&'static str>>,
|
||||
}
|
||||
|
||||
impl FakeWorker {
|
||||
fn new(s: &Session, slots: usize) -> FakeWorker {
|
||||
FakeWorker { slots, state: Mutex::new((Mirror::default(), FrameServer::new(s.footage.clone(), s.expr.clone()), vec![], 0)), syncs: Mutex::default() }
|
||||
}
|
||||
fn pending(&self) -> usize {
|
||||
self.state.lock().unwrap().2.len()
|
||||
}
|
||||
fn take(&self) -> Vec<(RemoteDone, RemoteFrame)> {
|
||||
std::mem::take(&mut self.state.lock().unwrap().2)
|
||||
}
|
||||
/// Post every rendered frame back.
|
||||
fn deliver(&self) {
|
||||
for (done, f) in self.take() {
|
||||
done(Ok(f));
|
||||
}
|
||||
}
|
||||
/// The worker died: its frames never arrive.
|
||||
fn lose(&self) {
|
||||
for (done, _) in self.take() {
|
||||
done(Err("worker lost".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rt<T: serde::Serialize + for<'de> serde::Deserialize<'de>>(v: &T) -> T {
|
||||
serde_json::from_str(&serde_json::to_string(v).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
impl RemoteFrames for FakeWorker {
|
||||
fn slots(&self) -> usize {
|
||||
self.slots
|
||||
}
|
||||
fn start(&self, job: RemoteJob, done: RemoteDone) {
|
||||
let mut st = self.state.lock().unwrap();
|
||||
let (mirror, server, pending, next) = &mut *st;
|
||||
if let Some(m) = mirror.sync(job.revision, &job.project) {
|
||||
self.syncs.lock().unwrap().push(if matches!(m, FrameMsg::Patch { .. }) { "patch" } else { "project" });
|
||||
assert!(server.handle(rt(&m)).is_empty());
|
||||
}
|
||||
*next += 1;
|
||||
let out = server.handle(rt(&FrameMsg::Render { id: *next, revision: job.revision, comp: job.comp, time: job.t, opts: Box::new(job.opts) }));
|
||||
match out.into_iter().next() {
|
||||
Some((FrameReply::Frame { width, height, ms, .. }, Some(rgba))) => pending.push((done, RemoteFrame { width, height, rgba, ms })),
|
||||
other => done(Err(format!("{:?}", other.map(|o| o.0)))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn source(s: &Session) -> RenderSource {
|
||||
RenderSource {
|
||||
project: s.project.clone(),
|
||||
footage: s.footage.clone(),
|
||||
expr: s.expr.clone(),
|
||||
layer_cache: s.layer_cache.clone(),
|
||||
gpu: None,
|
||||
gpu_display: false,
|
||||
disk: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_frames_render_remotely_from_project_diffs() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let comp = s.active_comp_id().unwrap();
|
||||
let c = s.project.comp(comp).unwrap().clone();
|
||||
let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() };
|
||||
let key = |rev: u64, f: i64| FrameKey { revision: rev, comp: comp.0, frame: f, scale: 250, view: 0 };
|
||||
let worker = Arc::new(FakeWorker::new(&s, 2));
|
||||
let mut frames = Frames::default();
|
||||
frames.set_remote(Some(worker.clone()));
|
||||
assert!(frames.remote_active());
|
||||
// Three prefetch frames, then the viewer's frame.
|
||||
for f in 1..=3 {
|
||||
frames.request(&source(&s), key(s.revision, f), comp, c.frame_rate.tick_of(f), opts);
|
||||
}
|
||||
frames.request_urgent(&source(&s), key(s.revision, 10), comp, c.frame_rate.tick_of(10), opts);
|
||||
assert_eq!(frames.inflight(), 4);
|
||||
// Nothing renders until dispatched (no frame threads with a remote renderer); the viewer's
|
||||
// frame goes first, then prefetch in request order.
|
||||
assert_eq!(frames.dispatch_remote(), 2);
|
||||
assert_eq!(frames.remote_busy(), 2);
|
||||
assert_eq!(worker.pending(), 2);
|
||||
assert_eq!(frames.dispatch_remote(), 0, "both slots are busy");
|
||||
assert_eq!(*worker.syncs.lock().unwrap(), vec!["project"], "the first sync is the whole project");
|
||||
// Frames arrive: the cache holds exactly what the page would have rendered.
|
||||
worker.deliver();
|
||||
assert_eq!(frames.remote_busy(), 0);
|
||||
let img = match frames.get(&key(s.revision, 10)).expect("viewer frame cached") {
|
||||
FrameImage::Cpu(c) => c,
|
||||
FrameImage::Gpu(_) => unreachable!(),
|
||||
};
|
||||
let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts));
|
||||
assert_eq!(img.size, local.size);
|
||||
assert_eq!(img.pixels, local.pixels);
|
||||
assert!(frames.is_cached(&key(s.revision, 1)));
|
||||
assert!(!frames.is_cached(&key(s.revision, 2)));
|
||||
// The rest; an edit later travels as a patch.
|
||||
frames.dispatch_remote();
|
||||
worker.deliver();
|
||||
frames.dispatch_remote();
|
||||
worker.deliver();
|
||||
assert_eq!(frames.inflight(), 0);
|
||||
s.execute("layer.newSolid", json!({"color": "#ff8800"})).unwrap();
|
||||
frames.request_urgent(&source(&s), key(s.revision, 10), comp, c.frame_rate.tick_of(10), opts);
|
||||
frames.dispatch_remote();
|
||||
worker.deliver();
|
||||
assert_eq!(worker.syncs.lock().unwrap().last(), Some(&"patch"));
|
||||
let FrameImage::Cpu(img) = frames.get(&key(s.revision, 10)).unwrap() else { unreachable!() };
|
||||
let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts));
|
||||
assert_eq!(img.pixels, local.pixels);
|
||||
// A lost frame is released (requested again later), not cached.
|
||||
frames.request(&source(&s), key(s.revision, 20), comp, c.frame_rate.tick_of(20), opts);
|
||||
frames.dispatch_remote();
|
||||
worker.lose();
|
||||
assert_eq!(frames.inflight(), 0);
|
||||
assert!(!frames.is_cached(&key(s.revision, 20)));
|
||||
// Without slots the remote renderer is unusable (frames render here again).
|
||||
frames.set_remote(Some(Arc::new(FakeWorker::new(&s, 0))));
|
||||
assert!(!frames.remote_active());
|
||||
}
|
||||
|
||||
/// Holds offloaded jobs until the test runs them.
|
||||
#[derive(Default)]
|
||||
struct Manual(Mutex<Vec<(WorkerRequest, Arc<Inbox>)>>);
|
||||
|
||||
impl Offload for Manual {
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
|
||||
self.0.lock().unwrap().push((req, inbox));
|
||||
Ok(())
|
||||
}
|
||||
fn cancel(&self, _: u64) {}
|
||||
}
|
||||
|
||||
fn exporter() -> Arc<effectcraft_host::FileExporter> {
|
||||
let sink: Arc<dyn Fn(&str, Vec<u8>) + Send + Sync> = Arc::new(|_: &str, _: Vec<u8>| {});
|
||||
Arc::new(effectcraft_host::FileExporter { sink: Some(sink) })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_commands_reply_when_their_offloaded_job_ends() {
|
||||
let off = Arc::new(Manual::default());
|
||||
let mut s = Session { offload: Some(off.clone()), exporter: Some(exporter()), ..Default::default() };
|
||||
s.execute("comp.new", json!({"name": "W", "width": 64, "height": 48, "frameRate": 10, "duration": 1})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#406080"})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"format": "gif", "output": "/w.gif"})).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_eframe(|_| EffectcraftApp::new(s).with_control(rx));
|
||||
h.run_steps(2);
|
||||
// `renderQueue.render` waits by default: the job goes to the worker, the reply waits.
|
||||
let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "renderQueue.render", "params": {}}));
|
||||
tx.send(req).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(reply.try_recv().unwrap_err(), TryRecvError::Empty, "no reply while the job runs");
|
||||
assert!(h.state().session.is_rendering());
|
||||
let (job, inbox) = off.0.lock().unwrap().pop().expect("the render went to the worker");
|
||||
// The worker runs it.
|
||||
let mut w = Session { exporter: Some(exporter()), ..Default::default() };
|
||||
let post: Post = std::rc::Rc::new(move |r| inbox.push(r));
|
||||
effectcraft_engine::offload::run_request(&mut w, job, &post);
|
||||
h.run_steps(4);
|
||||
let v = reply.try_recv().expect("replied once the job ended");
|
||||
assert_eq!(v["ok"], true, "{v}");
|
||||
assert_eq!(v["result"]["rendering"], false, "{v}");
|
||||
assert_eq!(v["result"]["items"][0]["status"], "Done", "{v}");
|
||||
// `wait: false` replies at once.
|
||||
h.state_mut().session.execute("renderQueue.add", json!({"format": "gif", "output": "/w2.gif"})).unwrap();
|
||||
let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "renderQueue.render", "params": {"wait": false}}));
|
||||
tx.send(req).unwrap();
|
||||
h.run_steps(2);
|
||||
let v = reply.try_recv().expect("immediate reply");
|
||||
assert_eq!(v["result"]["rendering"], true, "{v}");
|
||||
}
|
||||
+5
-5
@@ -15,8 +15,8 @@ How close EffectCraft is to After Effects 2026, feature by feature, and how much
|
||||
| Remaining work | ≈ 19–22 agent-hours at the pace measured so far (≈ 58 on the conservative audit scale) |
|
||||
| **Wall-clock estimate** | **≈ 4–5 hours** with five agents in parallel; ≈ 3 hours for 100% of P0 + P1 |
|
||||
|
||||
Partial features: GPU coverage (Advanced 3D, more GPU effects), the web app (no threads for
|
||||
viewer frames and Roto Brush), Essential Graphics mirrored properties, and the learned-model quality
|
||||
Partial features: GPU coverage (Advanced 3D, more GPU effects), the web app (GPU effects and
|
||||
the disk cache in the browser; M13.10 moved viewer frames and Roto Brush off the page's thread), Essential Graphics mirrored properties, and the learned-model quality
|
||||
of Roto Brush and face tracking (both are classical). Since this audit, M9.13 completed all 42
|
||||
simplified effects, M13.5 added the Learn panel, and M13.8 the Preview panel settings, Align to
|
||||
Selection / Distribute and Advanced 3D iris DOF, collapsed precomps and extruded strokes. M13.7 added ScriptUI resource strings,
|
||||
@@ -134,10 +134,10 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
|
||||
| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' mirrored properties (CMP-7: Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
|
||||
| Animation | 70% | 9.0 | puppet depth beyond pins and recording (puppet pin recording with Record Options landed in M13.1), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
|
||||
| Text | ≈ 95% | 0.4 | no extruded strokes, the Variable Font Axes animator changes outlines but not advances (M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
|
||||
| Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) |
|
||||
| Web | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10) |
|
||||
| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
|
||||
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (152 run on the GPU since M13.9, EFF-5: keying, mattes, channel, colour correction, stylize, distort, noise, blurs and time effects joined the 58 of M12.7; Warp, Bezier Warp, Reshape, Smear, Cartoon, CC Page Turn and others still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
|
||||
| Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
|
||||
| Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
|
||||
| Preview | 74% | 3.2 | wireframes and Advanced 3D compositing on the GPU (Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
@@ -239,7 +239,7 @@ precompose), **Auto-trace** (alpha / RGB / luminance, current frame or work area
|
||||
or a new layer) and **Align Video to Data** (JSON / CSV / TSV time keys) replace their disabled
|
||||
menu entries. Not yet: creating a reference frame by editing a still (After Effects hands it to
|
||||
Photoshop), Content-Aware Fill's learned model (ours is PatchMatch + flow propagation), and Media
|
||||
Browser in the web app.
|
||||
Browser in the web app (landed in M13.10: browser storage and File System Access folders).
|
||||
|
||||
## Highest-value gaps, in order
|
||||
|
||||
|
||||
+76
-10
@@ -41,14 +41,15 @@ with every L0–L4 crate and `effectcraft-ui-egui`, for `wasm32-unknown-unknown`
|
||||
|
||||
URL flags: `?empty` (a blank project; the session is not restored or recorded), `?demo` (the demo
|
||||
project instead of the last session), `?home` (show the start screen), `?storage=indexeddb` /
|
||||
`?storage=memory` (force a storage backend), `?noworkers` (render and analyse on the page's
|
||||
thread), `?nosw` (don't register the service worker).
|
||||
`?storage=memory` (force a storage backend), `?noworkers` (render, analyse and draw viewer
|
||||
frames on the page's thread), `?frameworkers=N` (frame workers, default 2; 0 = viewer frames on
|
||||
the page's thread), `?nosw` (don't register the service worker).
|
||||
|
||||
## How the desktop pieces map to the browser
|
||||
|
||||
| Desktop | Web |
|
||||
|---|---|
|
||||
| frame render thread pool (`ui-egui/src/frames.rs`) | `Frames::pump`: queued frames render on the UI thread after each egui frame, most important first (the viewer's frame, then prefetch), within 40 ms (24 ms while playing) |
|
||||
| frame render thread pool (`ui-egui/src/frames.rs`) | frame workers (below): `Frames::pump` hands queued frames to them, most important first (the viewer's frame, then prefetch); frames the GPU compositor can keep on the GPU render on the page. Without workers, queued frames render on the UI thread after each egui frame within 40 ms (24 ms while playing) |
|
||||
| Render Queue on a background thread | a Web Worker running a second engine instance (below); progress streams back, the page never waits |
|
||||
| tracker / mask tracker / Warp Stabilizer / 3D Camera Tracker / Roto Brush Freeze threads | the same workers; the analysed property group comes back as one undo step |
|
||||
| `rayon` parallel loops (raster, effects, export batches) | rayon's global pool falls back to the calling thread when threads are unavailable: same code, serial |
|
||||
@@ -59,6 +60,7 @@ thread), `?nosw` (don't register the service worker).
|
||||
| media reads (`MediaPool`, `probe`) | `files::WebImporter`: bytes from the file table, handed to `MediaPool::add_bytes` / `probe_bytes` |
|
||||
| export writes (`effectcraft-export`) | the job's `sink` (`effectcraft_host::FileExporter { sink }`): files land in the table and download after the render; several files (an image sequence) download as one stored `.zip` |
|
||||
| rfd file dialogs | `<input type=file>` for File ▸ Open / Import; drop files anywhere on the page (`.ecproj` opens, everything else imports); "Save As" / "Output To" pick a download name |
|
||||
| Media Browser on the file system | browser storage and folders opened with the File System Access API (below) |
|
||||
| system fonts | not scanned; the bundled fonts (Inter, Noto Serif, JetBrains Mono) are always there |
|
||||
| TCP control channel / MCP | `window.effectcraft` (below) |
|
||||
| cpal audio output | Web Audio (below) |
|
||||
@@ -109,10 +111,65 @@ compiled twice) and its own memory (`crates/engine/src/offload.rs`, `src/worker.
|
||||
tracker / mask group is replaced; other edits made meanwhile are kept) and the downloads start.
|
||||
|
||||
Stopping a job terminates its worker (a worker can't be interrupted mid-frame): the item being
|
||||
rendered becomes "User Stopped", an analysis writes nothing. `renderQueue.render {wait: true}` (the
|
||||
default for agents) and analysis commands with `wait: true` still run on the page's thread and
|
||||
return when done. Roto Brush propagation fills the session's segmentation cache rather than the
|
||||
project, so it runs on the page's thread too, as do viewer frames (`Frames::pump`).
|
||||
rendered becomes "User Stopped", an analysis writes nothing. Every job shows in Window ▸ Progress
|
||||
(`jobs.list`) with its progress and a cancel button (`jobs.cancel`).
|
||||
|
||||
**Roto Brush propagation** runs in a job worker too (`WorkerJob::RotoPropagate`): the worker
|
||||
streams the segmentations it computes back at most every 250 ms (`WorkerReply::Segs`: each
|
||||
`FrameSeg` in a compact binary form, run-length coded planes, base64), the page puts them into its
|
||||
segmentation cache and relays them to the frame workers. Stopping it keeps the frames propagated
|
||||
so far.
|
||||
|
||||
**`wait: true`** (the default of `renderQueue.render` for agents, and an option of the analysis
|
||||
and Roto Brush commands) no longer blocks the page: over `window.effectcraft` (the control
|
||||
channel) such a command runs with `wait: false`, and the promise resolves when the job it started
|
||||
ends, with what the blocking command returns (final render item statuses, the analysis status)
|
||||
or rejects with the job's error (`ui-egui/src/control.rs`, `JobWaiter`). Engine commands executed
|
||||
directly on the session (scripts) still block.
|
||||
|
||||
### Viewer frames: frame workers
|
||||
|
||||
Viewer frames (the frame on screen, RAM preview and prefetch) render in long-lived **frame
|
||||
workers** (`src/frames.rs`, `crates/engine/src/remote.rs`), two by default. Each holds a replica
|
||||
of the project:
|
||||
|
||||
1. Before a render, the page sends a worker the footage files it lacks, then a sync: the whole
|
||||
project the first time, afterwards a **diff** of the serialized project from the revision the
|
||||
worker holds (`remote::diff`: object members added / changed / removed, arrays element-wise
|
||||
when their length is unchanged; a slider drag sends a few hundred bytes, not the project).
|
||||
2. Then the render request (`FrameMsg::Render`: revision, comp, time, render options). The worker
|
||||
renders on its CPU renderer and posts the frame back as premultiplied RGBA8 in a transferred
|
||||
buffer (`FrameReply::Frame`); the page puts it in the frame cache.
|
||||
3. One frame per worker at a time, so the page always picks what renders next (the viewer's frame
|
||||
before prefetch); a request for a revision the worker does not hold is refused, a worker that
|
||||
lost its replica asks for the whole project again (`FrameReply::Resync`).
|
||||
|
||||
Frames the WebGPU compositor can finish alone still render on the page (they stay on the GPU, no
|
||||
readback); everything else (CPU-only effects, 3D runs, the Software Only renderer, WebGL2) goes to
|
||||
the workers. If no worker is usable, frames render on the page's thread as before.
|
||||
`effectcraft.info().frameWorkers` reports `{workers, alive, busy, rendered, failed, lastMs,
|
||||
syncs, syncBytes}`.
|
||||
|
||||
**Why workers and not wasm threads:** shared-memory threads (SharedArrayBuffer + atomics, a
|
||||
`wasm-bindgen-rayon`-style pool) need the standard library rebuilt with atomics (`build-std`,
|
||||
nightly Rust); the repository builds on stable, so each worker runs its own engine instance with
|
||||
its own memory. The page is still cross-origin isolated (`--serve` and the service worker send
|
||||
COOP / COEP), so a threaded build can be added later without changing the deployment.
|
||||
|
||||
### Media Browser
|
||||
|
||||
Window ▸ Media Browser browses a virtual tree (`src/browse.rs`):
|
||||
|
||||
- **Browser Storage**: every file in browser storage (imported media, saved projects, files added
|
||||
with **Add Files…** or **Upload Folder…**, plain file inputs that work in every browser). This is
|
||||
the persistent recent list where folders can't be opened.
|
||||
- **Folders**: **Open Folder…** (File System Access API, `showDirectoryPicker`, Chromium) lists a
|
||||
folder from the user's disk; only the listing is read, and a file's bytes are read when it is
|
||||
imported (then kept in browser storage). The folder handles are remembered in IndexedDB; after
|
||||
a reload the folders come back if the browser still grants access, else **Reconnect <name>**
|
||||
asks again.
|
||||
|
||||
`mediaBrowser.list` reports the places and these actions; `mediaBrowser.action {action}` runs one.
|
||||
|
||||
### Audio
|
||||
|
||||
@@ -192,15 +249,24 @@ cargo xtask web --serve 8765 &
|
||||
node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out target/web/smoke
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Native unit tests cover the storage model (`apps/effectcraft-web/src/store.rs`: write
|
||||
coalescing, file table, `ConfigStore` / `FileOps`, auto-save and crash recovery through the
|
||||
browser store) and the worker protocol (`crates/engine/src/offload.rs`: serde round trips of
|
||||
every request and reply, a render through an in-process offload).
|
||||
every request and reply, a render through an in-process offload; `tests_roto.rs`: Roto Brush
|
||||
propagation through an offload streaming segmentations), the frame-worker protocol
|
||||
(`crates/engine/src/remote.rs`: diff / patch, a replica following edits renders the same pixels;
|
||||
`crates/ui-egui/tests/ui_web_offload.rs`: the frame cache through a remote renderer, `wait: true`
|
||||
deferred until the offloaded job ends) and the Media Browser's virtual tree (`media_browser.rs`,
|
||||
`tests_panels.rs`).
|
||||
|
||||
## Gaps
|
||||
|
||||
- Viewer frames, RAM preview and Roto Brush propagation still render on the page's thread (one
|
||||
frame at a time between UI frames).
|
||||
- No shared-memory threads: each worker is a separate engine instance (memory per worker: the
|
||||
project replica and the footage it reads).
|
||||
- GPU effects run only in the desktop GPU path: in the browser, effects run on the CPU (inside
|
||||
workers for renders) because the page's thread can't wait for mid-render readbacks.
|
||||
- No disk cache (Settings ▸ Media & Disk Cache) in the browser: the layer cache stays in memory.
|
||||
|
||||
Reference in New Issue
Block a user