M13.10: web frame workers fed by project diffs, offloaded Roto Brush propagation, non-blocking wait jobs, browser Media Browser

Viewer frames render in long-lived Web Workers holding a project replica
(engine::remote: structural JSON diff/patch, Mirror, FrameServer); the
frame cache hands jobs to a RemoteFrames renderer (UI-thread fallback).
Roto Brush propagation runs in a job worker and streams segmentations
back (FrameSeg binary form), relayed to frame workers. Control-channel
wait:true commands whose job is offloaded reply when it ends. The Media
Browser browses a pluggable Browser: the file system on the desktop;
browser storage and File System Access folders on the web.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 08:36:00 -04:00
parent 0f591e0856
commit 5bb6768766
23 changed files with 2197 additions and 79 deletions

View File

@ -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();
});
}

View File

@ -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);

View File

@ -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}`"))
}
}
}
}

View File

@ -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:?}");
}
}
}

View File

@ -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;

View File

@ -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) {

View File

@ -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);
};

View File

@ -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),

View File

@ -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;
@ -349,6 +350,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`).
@ -421,6 +427,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,

View File

@ -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");

View File

@ -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,
] {

384
crates/engine/src/remote.rs Normal file
View File

@ -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);
}
}
}

View File

@ -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(&params_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(&params, 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() {

View File

@ -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());
}

View File

@ -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());
}

View File

@ -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,

View File

@ -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).

View File

@ -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]

View File

@ -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, &params) {
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)

View File

@ -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)
}
}

View File

@ -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) {

View File

@ -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())),
);

View File

@ -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}");
}