M15.2: web app depth (WEB-1): browser storage, Web Audio, worker renders, WebGPU viewer, PWA
- Persistence: settings, shortcuts, recent projects, imported media, saved projects and auto-saves live in OPFS (IndexedDB fallback) through an in-memory mirror flushed in the background (apps/effectcraft-web/src/store.rs, persist.rs); ConfigStore gains FileOps so auto-save / recovery work in the browser store; the session snapshot restores the last project + editor state on reload; File > Open Recent works from storage. - Audio: Web Audio output (AudioWorklet, ScriptProcessor fallback) fed by AudioPlayback::pump on wasm32; A/V sync from AudioContext.currentTime; meters; unlock on first gesture. - Background jobs: engine::offload (WorkerRequest/WorkerReply protocol, Offload trait, run_request); Render Queue, tracker, mask tracker, Warp Stabilizer and Roto Freeze run in Web Workers (a second engine instance on the shared compiled module), progress streamed back; cancel terminates the worker. - GPU: WebGPU viewer frames drawn straight from the compositor's texture (gpu_display on wasm32 when the viewer draws plainly); async readback (Gpu::read_display_async) for pixels. - PWA: manifest, service worker precaching the build (xtask fills list/version), icons copied from assets/app-icon; COOP/COEP kept from cached responses; offline reload works. - window.effectcraft: saveToBrowser, listStored, removeStored, flush; info() reports storage, audio, gpu, workers. - Tests: native store + protocol + offload (render, track, cancel) tests; smoke.mjs extended (GPU path, worker render responsiveness, audio gesture + playback, reload persistence, Open Recent, offline reload). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c406b71cba
commit
0bee1ab2da
|
|
@ -1511,10 +1511,12 @@ dependencies = [
|
|||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ once.
|
|||
| M11 | Animation tools: audio playback, meters and waveforms, Lottie import and export (done); presets, Motion Sketch, Wiggler | In progress |
|
||||
| M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress |
|
||||
| M13+ | Puppet, paint, roto, motion tracking, a plugin API | Planned |
|
||||
| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded |
|
||||
| M15 | The web app (WebAssembly, WebGPU) | Done: browser storage, Web Audio, renders and analyses in Web Workers, offline install ([docs/web.md](docs/web.md)) |
|
||||
| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done |
|
||||
|
||||
## How far from full parity
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "effectcraft-web"
|
||||
description = "EffectCraft in the browser (wasm32): eframe web runner, in-memory files, browser file pickers and downloads, window.effectcraft API"
|
||||
description = "EffectCraft in the browser (wasm32): eframe web runner, persistent browser storage (OPFS / IndexedDB), Web Audio, Web Worker renders, PWA, window.effectcraft API"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
|
@ -12,10 +12,14 @@ workspace = true
|
|||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# Everything is wasm32-only: on other targets this crate is empty (so `cargo test --workspace`
|
||||
# and clippy stay cheap). Build with `cargo xtask web` (see docs/web.md).
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# The browser glue is wasm32-only; on other targets the crate holds just the portable storage
|
||||
# model (`store`), unit-tested natively. Build the app with `cargo xtask web` (see docs/web.md).
|
||||
[dependencies]
|
||||
effectcraft-engine = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
effectcraft-host = { workspace = true }
|
||||
effectcraft-media = { workspace = true }
|
||||
effectcraft-export = { workspace = true }
|
||||
|
|
@ -23,8 +27,8 @@ effectcraft-expr = { workspace = true }
|
|||
effectcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu"] }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
# The wasm-bindgen CLI used by `cargo xtask web` must be exactly this version.
|
||||
wasm-bindgen = "=0.2.129"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
|
|
@ -35,6 +39,7 @@ web-sys = { version = "0.3", features = [
|
|||
"CssStyleDeclaration",
|
||||
"Document",
|
||||
"Element",
|
||||
"DedicatedWorkerGlobalScope",
|
||||
"Event",
|
||||
"EventTarget",
|
||||
"File",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
// Browser services for the EffectCraft web app (imported by the wasm module through
|
||||
// wasm-bindgen; see src/persist.rs, src/audio.rs and src/worker.rs):
|
||||
//
|
||||
// - storage: the Origin Private File System, or IndexedDB where OPFS can't write;
|
||||
// - audio: a Web Audio output (AudioWorklet, ScriptProcessorNode fallback) fed stereo blocks;
|
||||
// - workers: a pool of Web Workers, each running its own instance of the engine.
|
||||
//
|
||||
// Plain functions only: the Rust side owns the logic.
|
||||
|
||||
// ------------------------------------------------------------------ storage
|
||||
|
||||
const DIR = "effectcraft";
|
||||
const DB = "effectcraft";
|
||||
const STORE = "entries";
|
||||
let backend = null;
|
||||
|
||||
// OPFS file names can't hold "/": keys are percent-encoded.
|
||||
const enc = (k) => encodeURIComponent(k);
|
||||
const dec = (n) => decodeURIComponent(n);
|
||||
|
||||
async function opfs() {
|
||||
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no OPFS");
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const dir = await root.getDirectoryHandle(DIR, { create: true });
|
||||
// Probe a write: some browsers expose OPFS but only write it from workers.
|
||||
const probe = await dir.getFileHandle(".probe", { create: true });
|
||||
if (!probe.createWritable) throw new Error("OPFS is read-only on this thread");
|
||||
const w = await probe.createWritable();
|
||||
await w.write(new Uint8Array([1]));
|
||||
await w.close();
|
||||
await dir.removeEntry(".probe");
|
||||
return {
|
||||
name: "opfs",
|
||||
async loadAll() {
|
||||
const out = [];
|
||||
for await (const [name, h] of dir.entries()) {
|
||||
if (h.kind !== "file" || name.startsWith(".") || name.endsWith(".crswap")) continue;
|
||||
const f = await h.getFile();
|
||||
out.push({ key: dec(name), data: new Uint8Array(await f.arrayBuffer()), modified: f.lastModified });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
async put(key, data) {
|
||||
// createWritable writes a swap file and replaces the entry on close: atomic.
|
||||
const h = await dir.getFileHandle(enc(key), { create: true });
|
||||
const w = await h.createWritable();
|
||||
await w.write(data);
|
||||
await w.close();
|
||||
},
|
||||
async del(key) {
|
||||
try { await dir.removeEntry(enc(key)); } catch (e) { if (e.name !== "NotFoundError") throw e; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function req(r) {
|
||||
return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
|
||||
}
|
||||
|
||||
async function idb() {
|
||||
if (!self.indexedDB) throw new Error("no IndexedDB");
|
||||
const open = indexedDB.open(DB, 1);
|
||||
open.onupgradeneeded = () => open.result.createObjectStore(STORE, { keyPath: "key" });
|
||||
const db = await req(open);
|
||||
const tx = (mode) => db.transaction(STORE, mode).objectStore(STORE);
|
||||
return {
|
||||
name: "indexeddb",
|
||||
async loadAll() {
|
||||
const all = await req(tx("readonly").getAll());
|
||||
return all.map((r) => ({ key: r.key, data: new Uint8Array(r.data), modified: r.modified }));
|
||||
},
|
||||
async put(key, data, modified) {
|
||||
await req(tx("readwrite").put({ key, data: data.slice().buffer, modified }));
|
||||
},
|
||||
async del(key) {
|
||||
await req(tx("readwrite").delete(key));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function memory() {
|
||||
const m = new Map();
|
||||
return {
|
||||
name: "memory",
|
||||
async loadAll() { return [...m.values()]; },
|
||||
async put(key, data, modified) { m.set(key, { key, data: data.slice(), modified }); },
|
||||
async del(key) { m.delete(key); },
|
||||
};
|
||||
}
|
||||
|
||||
/// Open the store: OPFS, else IndexedDB, else memory (nothing persists). Resolves with the
|
||||
/// backend's name. `prefer` ("opfs" | "indexeddb" | "memory") forces one (tests).
|
||||
export async function storeOpen(prefer) {
|
||||
const order = { opfs: [opfs, idb, memory], indexeddb: [idb, memory], memory: [memory] }[prefer] ?? [opfs, idb, memory];
|
||||
for (const make of order) {
|
||||
try { backend = await make(); break; } catch (e) { console.info(`storage: ${make.name}: ${e.message ?? e}`); }
|
||||
}
|
||||
// Ask the browser not to evict the store under storage pressure (granted for installed apps).
|
||||
try { if (navigator.storage && navigator.storage.persist) await navigator.storage.persist(); } catch {}
|
||||
return backend.name;
|
||||
}
|
||||
|
||||
/// Every stored entry: [{key, data: Uint8Array, modified}].
|
||||
export async function storeLoadAll() { return backend.loadAll(); }
|
||||
export async function storePut(key, data, modified) { return backend.put(key, data, modified); }
|
||||
export async function storeDelete(key) { return backend.del(key); }
|
||||
|
||||
/// {usage, quota, persisted, backend}.
|
||||
export async function storeEstimate() {
|
||||
const out = { backend: backend ? backend.name : null };
|
||||
try {
|
||||
const e = await navigator.storage.estimate();
|
||||
out.usage = e.usage; out.quota = e.quota;
|
||||
out.persisted = await navigator.storage.persisted();
|
||||
} catch {}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ audio
|
||||
|
||||
// One AudioContext for the page. Browsers start it suspended until the user interacts with the
|
||||
// page, so the first pointer/key event resumes it ("unlock").
|
||||
let ctx = null;
|
||||
let node = null;
|
||||
let kind = null;
|
||||
// Sample frames handed to the output since `audioStart`, and the context time it started at.
|
||||
let posted = 0;
|
||||
let startTime = 0;
|
||||
let underruns = 0;
|
||||
// ScriptProcessor fallback: the queue lives on this thread.
|
||||
let spQueue = [];
|
||||
let spOffset = 0;
|
||||
// Blocks pushed while the output node is being created.
|
||||
let starting = false;
|
||||
let pendingPush = [];
|
||||
// Bumped by every start/stop: a start superseded while it awaited the worklet gives up.
|
||||
let generation = 0;
|
||||
|
||||
function context() {
|
||||
if (!ctx) {
|
||||
const AC = self.AudioContext || self.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
ctx = new AC({ latencyHint: "interactive" });
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
const c = context();
|
||||
if (c && c.state !== "running") c.resume().catch(() => {});
|
||||
}
|
||||
|
||||
/// Install the user-gesture unlock (called once at startup).
|
||||
export function audioInstallUnlock() {
|
||||
for (const ev of ["pointerdown", "keydown", "touchend"]) {
|
||||
self.addEventListener(ev, unlock, { capture: true, passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/// {state, sampleRate, backend, posted, played, underruns, baseLatency, outputLatency}.
|
||||
export function audioState() {
|
||||
const c = ctx;
|
||||
return {
|
||||
state: c ? c.state : "none",
|
||||
sampleRate: c ? c.sampleRate : 0,
|
||||
backend: kind,
|
||||
posted,
|
||||
played: audioPlayed(),
|
||||
underruns,
|
||||
baseLatency: c ? c.baseLatency || 0 : 0,
|
||||
outputLatency: c ? c.outputLatency || 0 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// The output's sample rate (creates the context; 0 without Web Audio).
|
||||
export function audioSampleRate() {
|
||||
const c = context();
|
||||
return c ? c.sampleRate : 0;
|
||||
}
|
||||
|
||||
/// Start an output node; resolves when it is ready. Samples pushed before then queue up.
|
||||
export async function audioStart(workletUrl) {
|
||||
audioStop();
|
||||
const c = context();
|
||||
if (!c) throw new Error("Web Audio is not available");
|
||||
unlock();
|
||||
posted = 0;
|
||||
underruns = 0;
|
||||
spQueue = [];
|
||||
spOffset = 0;
|
||||
starting = true;
|
||||
pendingPush = [];
|
||||
const gen = ++generation;
|
||||
if (c.audioWorklet && workletUrl) {
|
||||
try {
|
||||
if (!c.__ecWorklet) {
|
||||
await c.audioWorklet.addModule(workletUrl);
|
||||
c.__ecWorklet = true;
|
||||
}
|
||||
if (gen !== generation) return;
|
||||
node = new AudioWorkletNode(c, "effectcraft-output", { numberOfInputs: 0, outputChannelCount: [2] });
|
||||
node.port.onmessage = (e) => { if (e.data && e.data.underrun) underruns += e.data.underrun; };
|
||||
kind = "worklet";
|
||||
} catch (e) {
|
||||
console.warn("audio: AudioWorklet unavailable, using ScriptProcessorNode", e);
|
||||
node = null;
|
||||
}
|
||||
}
|
||||
if (!node) {
|
||||
node = c.createScriptProcessor(2048, 0, 2);
|
||||
node.onaudioprocess = (e) => {
|
||||
const l = e.outputBuffer.getChannelData(0);
|
||||
const r = e.outputBuffer.getChannelData(1);
|
||||
let i = 0;
|
||||
while (i < l.length && spQueue.length) {
|
||||
const b = spQueue[0];
|
||||
const n = Math.min(l.length - i, (b.length - spOffset) >> 1);
|
||||
for (let k = 0; k < n; k++) { l[i + k] = b[spOffset + 2 * k]; r[i + k] = b[spOffset + 2 * k + 1]; }
|
||||
i += n; spOffset += 2 * n;
|
||||
if (spOffset >= b.length) { spQueue.shift(); spOffset = 0; }
|
||||
}
|
||||
if (i < l.length) { underruns += l.length - i; l.fill(0, i); r.fill(0, i); }
|
||||
};
|
||||
kind = "scriptProcessor";
|
||||
}
|
||||
starting = false;
|
||||
if (!node) return;
|
||||
node.connect(c.destination);
|
||||
startTime = c.currentTime;
|
||||
const p = pendingPush;
|
||||
pendingPush = [];
|
||||
for (const b of p) send(b);
|
||||
}
|
||||
|
||||
function send(samples) {
|
||||
if (kind === "worklet") node.port.postMessage(samples, [samples.buffer]);
|
||||
else spQueue.push(samples);
|
||||
}
|
||||
|
||||
/// Queue interleaved stereo samples (Float32Array) for output.
|
||||
export function audioPush(samples) {
|
||||
posted += samples.length >> 1;
|
||||
if (node && !starting) send(samples);
|
||||
else if (starting) pendingPush.push(samples);
|
||||
}
|
||||
|
||||
/// Sample frames played since `audioStart`, by the context clock (AudioContext.currentTime).
|
||||
export function audioPlayed() {
|
||||
if (!ctx || !node || starting) return 0;
|
||||
return Math.max(0, Math.round((ctx.currentTime - startTime) * ctx.sampleRate));
|
||||
}
|
||||
|
||||
/// Output latency in sample frames (what's handed over but not audible yet comes on top).
|
||||
export function audioOutputLatency() {
|
||||
if (!ctx) return 0;
|
||||
return Math.round(((ctx.outputLatency || 0) + (ctx.baseLatency || 0)) * ctx.sampleRate);
|
||||
}
|
||||
|
||||
export function audioStop() {
|
||||
generation++;
|
||||
starting = false;
|
||||
pendingPush = [];
|
||||
if (node) {
|
||||
try { node.disconnect(); } catch {}
|
||||
if (kind === "worklet") node.port.postMessage("stop");
|
||||
}
|
||||
node = null;
|
||||
spQueue = [];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ workers
|
||||
|
||||
// Idle workers are reused; each keeps the files it was sent (path → size) so a file goes to a
|
||||
// worker once.
|
||||
const idle = [];
|
||||
const running = new Map(); // job id → {worker, done}
|
||||
|
||||
function spawn(base) {
|
||||
const worker = new Worker(new URL("worker.js", base), { type: "module", name: "effectcraft-worker" });
|
||||
const w = { worker, files: new Map(), ready: null };
|
||||
w.ready = new Promise((res, rej) => {
|
||||
worker.onmessage = (e) => { if (e.data && e.data.type === "ready") res(); };
|
||||
worker.onerror = (e) => rej(new Error(e.message || "worker failed to start"));
|
||||
});
|
||||
worker.postMessage({ type: "init", module: self.effectcraftModule ?? null, base });
|
||||
return w;
|
||||
}
|
||||
|
||||
/// Run a job in a worker. `files`: [[path, Uint8Array]] the job reads; `onMessage(type, json,
|
||||
/// path, bytes)` receives "reply" (json), "file" (path, bytes) and "error" (json = message).
|
||||
export function workerRun(id, json, files, base, onMessage) {
|
||||
const w = idle.pop() ?? spawn(base);
|
||||
running.set(id, w);
|
||||
w.ready.then(() => {
|
||||
w.worker.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === "reply") {
|
||||
onMessage("reply", m.json, null, null);
|
||||
if (m.done) {
|
||||
running.delete(id);
|
||||
idle.push(w);
|
||||
}
|
||||
} else if (m.type === "file") {
|
||||
onMessage("file", null, m.path, new Uint8Array(m.bytes));
|
||||
}
|
||||
};
|
||||
w.worker.onerror = (e) => {
|
||||
running.delete(id);
|
||||
onMessage("error", String(e.message || "worker error"), null, null);
|
||||
w.worker.terminate();
|
||||
};
|
||||
for (const [path, bytes] of files) {
|
||||
if (w.files.get(path) === bytes.length) continue;
|
||||
w.files.set(path, bytes.length);
|
||||
w.worker.postMessage({ type: "file", path, bytes });
|
||||
}
|
||||
w.worker.postMessage({ type: "job", json });
|
||||
}, (e) => {
|
||||
running.delete(id);
|
||||
onMessage("error", String(e.message || e), null, null);
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop a job: its worker is terminated (it can't be interrupted mid-job).
|
||||
export function workerCancel(id) {
|
||||
const w = running.get(id);
|
||||
if (!w) return;
|
||||
running.delete(id);
|
||||
w.worker.terminate();
|
||||
}
|
||||
|
||||
/// {running, idle}.
|
||||
export function workerStats() {
|
||||
return { running: running.size, idle: idle.length };
|
||||
}
|
||||
|
|
@ -92,9 +92,68 @@ pub fn info() -> JsValue {
|
|||
}
|
||||
v["version"] = json!(env!("CARGO_PKG_VERSION"));
|
||||
v["threads"] = json!(false);
|
||||
v["workers"] = from_js(&crate::worker::stats());
|
||||
v["audio"] = from_js(&crate::audio::state());
|
||||
if let Some(e) = crate::persist::last_error() {
|
||||
v["storage"]["error"] = json!(e);
|
||||
}
|
||||
to_js(&v)
|
||||
}
|
||||
|
||||
/// Save the project to browser storage without downloading it: `path` (default: its current
|
||||
/// path, or `/<project name>.ecproj`). Resolves with `{path, bytes}`.
|
||||
#[wasm_bindgen(js_name = saveToBrowser)]
|
||||
pub fn save_to_browser(path: Option<String>) -> js_sys::Promise {
|
||||
js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let path = path.clone();
|
||||
crate::post(move |app| {
|
||||
let p = path.or_else(|| app.session.path.clone()).unwrap_or_else(|| format!("/{}.ecproj", app.session.project_name()));
|
||||
let p = if p.starts_with('/') { p } else { format!("/{p}") };
|
||||
crate::files::save_quietly(&p);
|
||||
match app.session.execute("file.saveAs", json!({"path": p})) {
|
||||
Ok(v) => {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(&v));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &JsValue::from_str(&e.to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// What browser storage holds: `{backend, usage, quota, persisted, files: [{path, size,
|
||||
/// modified}], config: [name]}` (render outputs, which are not stored, are left out).
|
||||
#[wasm_bindgen(js_name = listStored)]
|
||||
pub async fn list_stored() -> JsValue {
|
||||
let est = wasm_bindgen_futures::JsFuture::from(crate::persist::store_estimate()).await.map(|v| from_js(&v)).unwrap_or(json!({}));
|
||||
let files: Vec<Value> = crate::files::STORE
|
||||
.files()
|
||||
.into_iter()
|
||||
.filter(|f| f.3)
|
||||
.map(|(path, size, modified, _)| json!({"path": path, "size": size, "modified": modified}))
|
||||
.collect();
|
||||
let config: Vec<Value> =
|
||||
crate::files::STORE.lock().list(crate::store::CONFIG).into_iter().map(|(k, ..)| json!(k.trim_start_matches(crate::store::CONFIG))).collect();
|
||||
let mut v = est;
|
||||
v["files"] = json!(files);
|
||||
v["config"] = json!(config);
|
||||
v["pending"] = json!(crate::files::STORE.lock().has_pending());
|
||||
to_js(&v)
|
||||
}
|
||||
|
||||
/// Remove a stored file (media, project, auto-save). Resolves with whether it existed.
|
||||
#[wasm_bindgen(js_name = removeStored)]
|
||||
pub fn remove_stored(path: String) -> bool {
|
||||
crate::files::STORE.remove_file(&path)
|
||||
}
|
||||
|
||||
/// Resolves once every change is written to browser storage.
|
||||
#[wasm_bindgen]
|
||||
pub fn flush() -> js_sys::Promise {
|
||||
crate::persist::flushed()
|
||||
}
|
||||
|
||||
/// Settle the promises whose replies arrived (called every frame).
|
||||
pub(crate) fn poll_replies() {
|
||||
PENDING.with(|p| {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
//! The app: startup (storage, session restore, hooks), the eframe app and the page glue.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
|
||||
use crate::{api, audio, files, persist, worker};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use serde_json::json;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
|
||||
type Action = Box<dyn FnOnce(&mut EffectcraftApp)>;
|
||||
|
||||
static WORKER: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Running inside a Web Worker (no page, no downloads).
|
||||
pub fn is_worker() -> bool {
|
||||
WORKER.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(crate) fn set_worker() {
|
||||
WORKER.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// `rel` resolved against the page's URL (`worker.js`, `audio-worklet.js`); `""` = the page's
|
||||
/// folder.
|
||||
pub fn page_url(rel: &str) -> String {
|
||||
let href = web_sys::window().and_then(|w| w.location().href().ok()).unwrap_or_default();
|
||||
web_sys::Url::new_with_base(if rel.is_empty() { "./" } else { rel }, &href).map(|u| u.href()).unwrap_or_else(|_| rel.to_string())
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ACTIONS: RefCell<Vec<Action>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run `f` on the UI thread at the next frame.
|
||||
pub fn post(f: impl FnOnce(&mut EffectcraftApp) + 'static) {
|
||||
ACTIONS.with(|a| a.borrow_mut().push(Box::new(f)));
|
||||
repaint();
|
||||
}
|
||||
|
||||
/// Ask for a UI frame.
|
||||
pub fn repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(c) = c.borrow().as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open a project or import media from table paths (picked or dropped files).
|
||||
pub(crate) fn open_or_import(app: &mut EffectcraftApp, paths: Vec<String>) {
|
||||
let (projects, media): (Vec<String>, Vec<String>) = paths.into_iter().partition(|p| p.to_ascii_lowercase().ends_with(".ecproj"));
|
||||
if let Some(p) = projects.last()
|
||||
&& let Err(e) = app.session.execute("file.open", json!({"path": p}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
if !media.is_empty() {
|
||||
match app.session.execute("file.import", json!({"paths": media})) {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties.
|
||||
pub struct WebApp {
|
||||
pub app: EffectcraftApp,
|
||||
/// Keep the session snapshot (off with `?empty`).
|
||||
pub persist: bool,
|
||||
}
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw_input);
|
||||
}
|
||||
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));
|
||||
for f in actions {
|
||||
f(&mut self.app);
|
||||
}
|
||||
// Files dropped on the page: read them, then open/import.
|
||||
let dropped: Vec<web_sys::File> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect());
|
||||
if !dropped.is_empty() {
|
||||
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
|
||||
}
|
||||
self.app.logic(ctx, frame);
|
||||
api::poll_replies();
|
||||
// Render Queue outputs written this frame (here or by a worker) → downloads.
|
||||
files::flush_downloads();
|
||||
// What a reload comes back to.
|
||||
if self.persist {
|
||||
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()}));
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui, frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn query() -> Option<web_sys::UrlSearchParams> {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok())
|
||||
}
|
||||
|
||||
fn query_flag(name: &str) -> bool {
|
||||
query().is_some_and(|p| p.has(name))
|
||||
}
|
||||
|
||||
fn query_value(name: &str) -> Option<String> {
|
||||
query().and_then(|p| p.get(name))
|
||||
}
|
||||
|
||||
/// A session wired for the browser: media decoding from memory, expressions, scripting, Render
|
||||
/// Queue export to downloads, settings in browser storage, renders and analyses in workers.
|
||||
pub fn session() -> Session {
|
||||
let pool = Arc::new(effectcraft_media::MediaPool::new());
|
||||
persist::register_media(&pool);
|
||||
Session {
|
||||
services: Arc::new(files::WebServices),
|
||||
footage: pool.clone(),
|
||||
importer: Some(Arc::new(files::WebImporter { pool })),
|
||||
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
||||
expr_check: Some(effectcraft_expr::check_syntax),
|
||||
script: Some(effectcraft_host::script::runner),
|
||||
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>),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser file pickers in place of the desktop's file dialogs.
|
||||
fn install_hooks(app: &mut EffectcraftApp) {
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
if let Err(e) = files::pick(&accept, true, |paths| post(move |app| open_or_import(app, paths))) {
|
||||
log::warn!("file picker: {e:?}");
|
||||
}
|
||||
// the import happens when the browser hands over the files
|
||||
Vec::new()
|
||||
}));
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
if let Err(e) = files::pick(".ecproj", false, |paths| post(move |app| open_or_import(app, paths))) {
|
||||
log::warn!("file picker: {e:?}");
|
||||
}
|
||||
None
|
||||
}));
|
||||
// Saving downloads the file: the chosen name is all a "save dialog" needs.
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name }))));
|
||||
app.hooks.audio_device = Some(Box::new(audio::open));
|
||||
app.hooks.audio_devices = Some(Box::new(|| vec!["Browser default output".into()]));
|
||||
}
|
||||
|
||||
/// Entry point (called by the page's bootstrap script once the wasm module is instantiated).
|
||||
#[wasm_bindgen]
|
||||
pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
|
||||
let empty = query_flag("empty");
|
||||
let home = query_flag("home");
|
||||
// Browser storage: settings, recent projects, imported media, saved projects, auto-saves and
|
||||
// the last session come back.
|
||||
let t0 = js_sys::Date::now();
|
||||
let stored = match persist::load(&query_value("storage").unwrap_or_default()).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
log::warn!("browser storage unavailable: {e}");
|
||||
0
|
||||
}
|
||||
};
|
||||
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0}));
|
||||
audio::install_unlock();
|
||||
let runner = eframe::WebRunner::new();
|
||||
runner
|
||||
.start(
|
||||
canvas,
|
||||
eframe::WebOptions::default(),
|
||||
Box::new(move |cc| {
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
let mut session = session();
|
||||
session.load_settings();
|
||||
let recovery = session.begin_recovery();
|
||||
// `?empty`: a blank project; `?demo`: the demo project; otherwise the last visit's
|
||||
// project (the demo on a first visit).
|
||||
let restored = !empty && !query_flag("demo") && persist::restore(&mut session);
|
||||
if !restored && !empty {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
api::set_info("restored", json!(restored));
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.ui.start_screen = home;
|
||||
if let Some(r) = recovery.filter(|r| !restored && r.autosave.is_some()) {
|
||||
app.offer_recovery(r);
|
||||
}
|
||||
install_hooks(&mut app);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend));
|
||||
api::set_info("backend", json!(backend));
|
||||
Ok(Box::new(WebApp { app, persist: !empty }))
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
// Hide the loading message once the app runs.
|
||||
if let Some(el) = doc.get_element_by_id("loading") {
|
||||
el.remove();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
//! Audio preview through Web Audio (`js/host.js`): an AudioWorklet (ScriptProcessorNode where
|
||||
//! worklets are unavailable) plays the blocks the UI mixes ahead
|
||||
//! ([`effectcraft_ui_egui::audio::AudioPlayback::pump`]).
|
||||
//!
|
||||
//! Every UI frame [`WebAudioOut::pump`] moves samples from the feed to the output so it stays
|
||||
//! ~150 ms ahead of what has played. A/V sync follows `AudioContext.currentTime`: frames played
|
||||
//! = (currentTime − start) × rate, so the device latency the playback clock subtracts is
|
||||
//! "handed over but not played yet" plus the context's output latency.
|
||||
//!
|
||||
//! Browsers keep the AudioContext suspended until the user interacts with the page; the host
|
||||
//! resumes it on the first pointer or key event.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = audioInstallUnlock)]
|
||||
pub fn install_unlock();
|
||||
#[wasm_bindgen(js_name = audioState)]
|
||||
pub fn state() -> JsValue;
|
||||
#[wasm_bindgen(js_name = audioSampleRate)]
|
||||
fn sample_rate() -> f64;
|
||||
#[wasm_bindgen(js_name = audioStart)]
|
||||
fn audio_start(worklet_url: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = audioPush)]
|
||||
fn audio_push(samples: js_sys::Float32Array);
|
||||
#[wasm_bindgen(js_name = audioPlayed)]
|
||||
fn audio_played() -> f64;
|
||||
#[wasm_bindgen(js_name = audioOutputLatency)]
|
||||
fn audio_output_latency() -> f64;
|
||||
#[wasm_bindgen(js_name = audioStop)]
|
||||
fn audio_stop();
|
||||
}
|
||||
|
||||
/// How far ahead of the playhead the output is kept fed.
|
||||
const LEAD_SECONDS: f64 = 0.15;
|
||||
|
||||
pub struct WebAudioOut {
|
||||
rate: u32,
|
||||
feed: Option<Arc<AudioFeed>>,
|
||||
/// Sample frames handed to the output since start.
|
||||
posted: u64,
|
||||
buf: Vec<f32>,
|
||||
}
|
||||
|
||||
/// The Settings ▸ Audio factory: Web Audio's default output (`None` without Web Audio).
|
||||
pub fn open(_: &effectcraft_ui_egui::audio::AudioOutput) -> Option<Box<dyn AudioDevice>> {
|
||||
let rate = sample_rate();
|
||||
(rate > 0.0).then(|| Box::new(WebAudioOut { rate: rate as u32, feed: None, posted: 0, buf: Vec::new() }) as Box<dyn AudioDevice>)
|
||||
}
|
||||
|
||||
fn worklet_url() -> String {
|
||||
crate::page_url("audio-worklet.js")
|
||||
}
|
||||
|
||||
impl AudioDevice for WebAudioOut {
|
||||
fn sample_rate(&self) -> u32 {
|
||||
self.rate
|
||||
}
|
||||
|
||||
fn start(&mut self, feed: Arc<AudioFeed>) -> Result<(), String> {
|
||||
self.feed = Some(feed);
|
||||
self.posted = 0;
|
||||
let p = audio_start(&worklet_url());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await {
|
||||
log::warn!("audio output: {e:?}");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
self.feed = None;
|
||||
audio_stop();
|
||||
}
|
||||
|
||||
fn latency_frames(&self) -> u64 {
|
||||
let played = audio_played() as u64;
|
||||
self.posted.saturating_sub(played) + audio_output_latency() as u64
|
||||
}
|
||||
|
||||
fn pump(&mut self) {
|
||||
let Some(feed) = &self.feed else { return };
|
||||
let played = audio_played() as u64;
|
||||
let lead = (LEAD_SECONDS * self.rate as f64) as u64;
|
||||
let want = (played + lead).saturating_sub(self.posted) as usize;
|
||||
// Only real samples: the output plays silence by itself when it runs dry.
|
||||
let n = want.min(feed.queued_frames());
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
self.buf.resize(n * 2, 0.0);
|
||||
feed.pull(&mut self.buf);
|
||||
audio_push(js_sys::Float32Array::from(&self.buf[..]));
|
||||
self.posted += n as u64;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WebAudioOut {
|
||||
fn drop(&mut self) {
|
||||
if self.feed.is_some() {
|
||||
audio_stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +1,55 @@
|
|||
//! Files in the browser: an in-memory file table in place of the file system, `<input type=file>`
|
||||
//! pickers for Open/Import, and downloads for everything the app writes (projects, renders).
|
||||
//! Files in the browser: a virtual file table in place of the file system, `<input type=file>`
|
||||
//! pickers for Open/Import, and downloads for what the user saves and renders.
|
||||
//!
|
||||
//! Picked or dropped files are read into the table under `/<name>`; the engine, the media pool and
|
||||
//! the exporter read and write through it ([`WebServices`], [`WebImporter`], [`export_sink`]).
|
||||
//! The table is the persistent [`Store`]'s file namespace: imported media, saved projects and
|
||||
//! auto-saves survive reloads ([`crate::persist`]); render outputs don't (they download).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
use effectcraft_engine::Services;
|
||||
use effectcraft_engine::project::Footage;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
static FILES: Mutex<Option<HashMap<String, Arc<[u8]>>>> = Mutex::new(None);
|
||||
use crate::store::Store;
|
||||
|
||||
/// The page's store (each worker has its own, unpersisted).
|
||||
pub static STORE: LazyLock<Arc<Store>> = LazyLock::new(|| {
|
||||
let s = Arc::new(Store::default());
|
||||
let _ = s.clock.set(js_sys::Date::now);
|
||||
s
|
||||
});
|
||||
/// Render outputs written since the last [`flush_downloads`].
|
||||
static OUTPUTS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
/// Saves that write to browser storage only (no download): `api.saveToBrowser`.
|
||||
static QUIET: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
fn with_files<R>(f: impl FnOnce(&mut HashMap<String, Arc<[u8]>>) -> R) -> R {
|
||||
f(FILES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_default())
|
||||
}
|
||||
|
||||
/// Store a file in the table.
|
||||
/// Store a file in the table (persisted).
|
||||
pub fn put(path: &str, data: Arc<[u8]>) {
|
||||
with_files(|m| m.insert(path.to_string(), data));
|
||||
STORE.put_file(path, data, true);
|
||||
}
|
||||
|
||||
pub fn get(path: &str) -> Option<Arc<[u8]>> {
|
||||
with_files(|m| m.get(path).cloned())
|
||||
STORE.file(path)
|
||||
}
|
||||
|
||||
/// `(path, size)` of every file in the table.
|
||||
pub fn list() -> Vec<(String, usize)> {
|
||||
let mut v: Vec<(String, usize)> = with_files(|m| m.iter().map(|(k, d)| (k.clone(), d.len())).collect());
|
||||
v.sort();
|
||||
v
|
||||
STORE.files().into_iter().map(|(p, n, _, _)| (p, n)).collect()
|
||||
}
|
||||
|
||||
/// The next write of `path` goes to browser storage only (no download).
|
||||
pub fn save_quietly(path: &str) {
|
||||
QUIET.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
|
||||
}
|
||||
|
||||
/// A rendered file (from the page's exporter or a worker): kept in the table unpersisted and
|
||||
/// downloaded at the next [`flush_downloads`].
|
||||
pub fn add_output(path: &str, data: Arc<[u8]>) {
|
||||
STORE.put_file(path, data, false);
|
||||
OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
|
||||
}
|
||||
|
||||
/// The table path for a picked/dropped file name.
|
||||
|
|
@ -85,7 +101,8 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), JsValue> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Engine file access (projects): reads from the table; writes go to the table and download.
|
||||
/// Engine file access (projects): reads from the table; writes go to the table (persisted) and
|
||||
/// download, except [`save_quietly`] saves.
|
||||
pub struct WebServices;
|
||||
|
||||
impl Services for WebServices {
|
||||
|
|
@ -96,6 +113,15 @@ impl Services for WebServices {
|
|||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
put(path, data.into());
|
||||
let quiet = {
|
||||
let mut q = QUIET.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let n = q.len();
|
||||
q.retain(|p| p != path);
|
||||
q.len() != n
|
||||
};
|
||||
if quiet || crate::is_worker() {
|
||||
return Ok(());
|
||||
}
|
||||
download(file_name(path), data).map_err(|e| std::io::Error::other(format!("download failed: {e:?}")))
|
||||
}
|
||||
}
|
||||
|
|
@ -116,10 +142,7 @@ impl effectcraft_engine::Importer for WebImporter {
|
|||
/// Render Queue output: files land in the table and are downloaded after the render
|
||||
/// ([`flush_downloads`]; several files, e.g. an image sequence, as one `.zip`).
|
||||
pub fn export_sink() -> Arc<effectcraft_export::Sink> {
|
||||
Arc::new(|path: &str, data: Vec<u8>| {
|
||||
put(path, data.into());
|
||||
OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()).push(path.to_string());
|
||||
})
|
||||
Arc::new(|path: &str, data: Vec<u8>| add_output(path, data.into()))
|
||||
}
|
||||
|
||||
/// Download the render outputs written since the last call.
|
||||
|
|
|
|||
|
|
@ -6,175 +6,31 @@
|
|||
//!
|
||||
//! | Desktop | Web |
|
||||
//! |---|---|
|
||||
//! | file system (`FsServices`, media reads, export writes) | in-memory file table ([`files`]) |
|
||||
//! | 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 |
|
||||
//! | 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 |
|
||||
//! | Render Queue thread | the render runs inline (the page waits until it is done) |
|
||||
//! | 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`]) |
|
||||
//!
|
||||
//! Everything here is `wasm32`-only; on other targets the crate is empty.
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
//! [`store`] is portable (unit-tested natively); everything else is `wasm32`-only.
|
||||
|
||||
pub mod store;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod app;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod audio;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod persist;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod worker;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use serde_json::json;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
|
||||
type Action = Box<dyn FnOnce(&mut EffectcraftApp)>;
|
||||
|
||||
thread_local! {
|
||||
static ACTIONS: RefCell<Vec<Action>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run `f` on the UI thread at the next frame.
|
||||
pub fn post(f: impl FnOnce(&mut EffectcraftApp) + 'static) {
|
||||
ACTIONS.with(|a| a.borrow_mut().push(Box::new(f)));
|
||||
repaint();
|
||||
}
|
||||
|
||||
/// Ask for a UI frame.
|
||||
pub fn repaint() {
|
||||
CTX.with(|c| {
|
||||
if let Some(c) = c.borrow().as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open a project or import media from table paths (picked or dropped files).
|
||||
fn open_or_import(app: &mut EffectcraftApp, paths: Vec<String>) {
|
||||
let (projects, media): (Vec<String>, Vec<String>) = paths.into_iter().partition(|p| p.to_ascii_lowercase().ends_with(".ecproj"));
|
||||
if let Some(p) = projects.last()
|
||||
&& let Err(e) = app.session.execute("file.open", json!({"path": p}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
if !media.is_empty() {
|
||||
match app.session.execute("file.import", json!({"paths": media})) {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The eframe app: the shared EffectCraft UI plus the browser host's per-frame duties.
|
||||
pub struct WebApp {
|
||||
pub app: EffectcraftApp,
|
||||
}
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw_input);
|
||||
}
|
||||
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));
|
||||
for f in actions {
|
||||
f(&mut self.app);
|
||||
}
|
||||
// Files dropped on the page: read them, then open/import.
|
||||
let dropped: Vec<web_sys::File> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect());
|
||||
if !dropped.is_empty() {
|
||||
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
|
||||
}
|
||||
self.app.logic(ctx, frame);
|
||||
api::poll_replies();
|
||||
// Render Queue outputs written this frame → downloads.
|
||||
files::flush_downloads();
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui, frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn query_flag(name: &str) -> bool {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).is_some_and(|p| p.has(name))
|
||||
}
|
||||
|
||||
/// A session wired for the browser: media decoding from memory, expressions, scripting, Render
|
||||
/// Queue export to downloads.
|
||||
pub fn session() -> Session {
|
||||
let pool = Arc::new(effectcraft_media::MediaPool::new());
|
||||
Session {
|
||||
services: Arc::new(files::WebServices),
|
||||
footage: pool.clone(),
|
||||
importer: Some(Arc::new(files::WebImporter { pool })),
|
||||
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
||||
expr_check: Some(effectcraft_expr::check_syntax),
|
||||
script: Some(effectcraft_host::script::runner),
|
||||
exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser file pickers in place of the desktop's file dialogs.
|
||||
fn install_hooks(app: &mut EffectcraftApp) {
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
if let Err(e) = files::pick(&accept, true, |paths| post(move |app| open_or_import(app, paths))) {
|
||||
log::warn!("file picker: {e:?}");
|
||||
}
|
||||
// the import happens when the browser hands over the files
|
||||
Vec::new()
|
||||
}));
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
if let Err(e) = files::pick(".ecproj", false, |paths| post(move |app| open_or_import(app, paths))) {
|
||||
log::warn!("file picker: {e:?}");
|
||||
}
|
||||
None
|
||||
}));
|
||||
// Saving downloads the file: the chosen name is all a "save dialog" needs.
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| Some(files::path_for(if name.is_empty() { "Untitled Project.ecproj" } else { name }))));
|
||||
}
|
||||
|
||||
/// Entry point (called by the page's bootstrap script once the wasm module is instantiated).
|
||||
#[wasm_bindgen]
|
||||
pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
|
||||
let demo = !query_flag("empty");
|
||||
let home = query_flag("home");
|
||||
let runner = eframe::WebRunner::new();
|
||||
runner
|
||||
.start(
|
||||
canvas,
|
||||
eframe::WebOptions::default(),
|
||||
Box::new(move |cc| {
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
let mut session = session();
|
||||
if demo {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.ui.start_screen = home;
|
||||
install_hooks(&mut app);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
let backend = cc.wgpu_render_state.as_ref().map(|rs| format!("{:?}", rs.adapter.get_info().backend));
|
||||
api::set_info("backend", json!(backend));
|
||||
Ok(Box::new(WebApp { app }))
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
// Hide the loading message once the app runs.
|
||||
if let Some(el) = doc.get_element_by_id("loading") {
|
||||
el.remove();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use app::*;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
//! Persistence: the [`crate::store::Store`] mirrored to the browser's storage (the Origin
|
||||
//! Private File System, IndexedDB where OPFS can't write, memory as a last resort; `js/host.js`).
|
||||
//!
|
||||
//! - [`load`] (before the app starts) opens the storage and reads every entry into the mirror.
|
||||
//! - Changes flush in the background ([`schedule_flush`], wired as the store's change hook):
|
||||
//! one write per changed key, in order, with nothing written twice concurrently.
|
||||
//! - The session snapshot ([`snapshot`]) records the open project and editor state every second
|
||||
//! or so while it changes, so a reload (or a crash) comes back to where the user was
|
||||
//! ([`restore`]).
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::config::ConfigStore;
|
||||
use effectcraft_engine::project::Project;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::files::STORE;
|
||||
use crate::store::{SNAPSHOT, SNAPSHOT_META, SnapshotMeta, WebConfig};
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = storeOpen)]
|
||||
fn store_open(prefer: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = storeLoadAll)]
|
||||
fn store_load_all() -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = storePut)]
|
||||
fn store_put(key: &str, data: js_sys::Uint8Array, modified: f64) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = storeDelete)]
|
||||
fn store_delete(key: &str) -> js_sys::Promise;
|
||||
#[wasm_bindgen(js_name = storeEstimate)]
|
||||
pub(crate) fn store_estimate() -> js_sys::Promise;
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static FLUSHING: Cell<bool> = const { Cell::new(false) };
|
||||
static BACKEND: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
||||
static LAST_ERROR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
|
||||
static FLUSH_WAITERS: std::cell::RefCell<Vec<js_sys::Function>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
static SNAP: Cell<(u64, f64)> = const { Cell::new((u64::MAX, 0.0)) };
|
||||
static LAST_META: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
||||
}
|
||||
|
||||
/// The storage in use: `opfs`, `indexeddb` or `memory`.
|
||||
pub fn backend() -> String {
|
||||
BACKEND.with(|b| b.borrow().clone())
|
||||
}
|
||||
|
||||
/// The last storage error (quota exceeded…), if any.
|
||||
pub fn last_error() -> Option<String> {
|
||||
LAST_ERROR.with(|e| e.borrow().clone())
|
||||
}
|
||||
|
||||
fn js_err(e: JsValue) -> String {
|
||||
e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
/// Open the browser storage and read everything into the store. `prefer` forces a backend
|
||||
/// (`?storage=indexeddb` / `memory`; empty = OPFS, then IndexedDB). Returns the entry count.
|
||||
pub async fn load(prefer: &str) -> Result<usize, String> {
|
||||
let name = JsFuture::from(store_open(prefer)).await.map_err(js_err)?;
|
||||
BACKEND.with(|b| *b.borrow_mut() = name.as_string().unwrap_or_default());
|
||||
let all = JsFuture::from(store_load_all()).await.map_err(js_err)?;
|
||||
let all: js_sys::Array = all.dyn_into().map_err(js_err)?;
|
||||
let n = all.length() as usize;
|
||||
{
|
||||
let mut m = STORE.lock();
|
||||
for e in all.iter() {
|
||||
let get = |k: &str| js_sys::Reflect::get(&e, &k.into()).unwrap_or(JsValue::UNDEFINED);
|
||||
let Some(key) = get("key").as_string() else { continue };
|
||||
let data: js_sys::Uint8Array = match get("data").dyn_into() {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
m.load(&key, data.to_vec().into(), get("modified").as_f64().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
let _ = STORE.on_change.set(schedule_flush);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Write pending changes to the browser storage (in the background; one flush at a time).
|
||||
pub fn schedule_flush() {
|
||||
if FLUSHING.with(|f| f.replace(true)) {
|
||||
return; // the running flush picks the new changes up
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
loop {
|
||||
let pending = STORE.lock().take_pending();
|
||||
if pending.is_empty() {
|
||||
break;
|
||||
}
|
||||
for p in pending {
|
||||
let r = match &p.data {
|
||||
Some(d) => JsFuture::from(store_put(&p.key, js_sys::Uint8Array::from(&d[..]), p.modified)).await,
|
||||
None => JsFuture::from(store_delete(&p.key)).await,
|
||||
};
|
||||
if let Err(e) = r {
|
||||
let msg = format!("saving {} to browser storage failed: {}", p.key, js_err(e));
|
||||
log::warn!("{msg}");
|
||||
LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
FLUSHING.with(|f| f.set(false));
|
||||
for w in FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut())) {
|
||||
let _ = w.call0(&JsValue::NULL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A promise that resolves once every change so far is in the browser storage.
|
||||
pub fn flushed() -> js_sys::Promise {
|
||||
js_sys::Promise::new(&mut |resolve, _| {
|
||||
if !FLUSHING.with(|f| f.get()) && !STORE.lock().has_pending() {
|
||||
let _ = resolve.call0(&JsValue::NULL);
|
||||
} else {
|
||||
FLUSH_WAITERS.with(|w| w.borrow_mut().push(resolve));
|
||||
schedule_flush();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The config store for the session.
|
||||
pub fn config() -> WebConfig {
|
||||
WebConfig::new(STORE.clone())
|
||||
}
|
||||
|
||||
/// Record the open project and editor state when they changed (checked at most every
|
||||
/// `min_secs`).
|
||||
pub fn snapshot(s: &Session, now: f64, min_secs: f64) {
|
||||
let (rev, at) = SNAP.with(|c| c.get());
|
||||
if now - at < min_secs {
|
||||
return;
|
||||
}
|
||||
SNAP.with(|c| c.set((rev, now)));
|
||||
let meta = SnapshotMeta { path: s.path.clone(), dirty: s.is_dirty(), state: serde_json::to_value(&s.state).ok(), saved: 0.0 };
|
||||
let key = serde_json::to_string(&meta).unwrap_or_default();
|
||||
if rev == s.revision && LAST_META.with(|m| *m.borrow() == key) {
|
||||
return;
|
||||
}
|
||||
let c = config();
|
||||
if rev != s.revision {
|
||||
let _ = c.write(SNAPSHOT, &s.project.to_json());
|
||||
}
|
||||
let _ = c.write(SNAPSHOT_META, &serde_json::to_string(&SnapshotMeta { saved: js_sys::Date::now(), ..meta }).unwrap_or_default());
|
||||
SNAP.with(|c| c.set((s.revision, now)));
|
||||
LAST_META.with(|m| *m.borrow_mut() = key);
|
||||
}
|
||||
|
||||
/// Reopen the last visit's project and editor state. Returns whether there was one.
|
||||
pub fn restore(s: &mut Session) -> bool {
|
||||
let c = config();
|
||||
let (Some(json), Some(meta)) = (c.read(SNAPSHOT), c.read(SNAPSHOT_META)) else { return false };
|
||||
let meta: SnapshotMeta = serde_json::from_str(&meta).unwrap_or_default();
|
||||
let p = match Project::from_json(&json) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::warn!("restoring the last session: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
s.replace_project(p, meta.path.clone());
|
||||
if let Some(st) = meta.state.and_then(|v| serde_json::from_value(v).ok()) {
|
||||
s.state = st;
|
||||
}
|
||||
if meta.dirty {
|
||||
// Unsaved changes stay unsaved.
|
||||
s.bump();
|
||||
}
|
||||
SNAP.with(|c| c.set((s.revision, 0.0)));
|
||||
log::info!("restored the last session ({})", meta.path.as_deref().unwrap_or("untitled"));
|
||||
true
|
||||
}
|
||||
|
||||
/// Register persisted media with the media pool (so restored projects find their footage).
|
||||
pub fn register_media(pool: &effectcraft_media::MediaPool) -> usize {
|
||||
let mut n = 0;
|
||||
for (path, _, _, persisted) in STORE.files() {
|
||||
if persisted
|
||||
&& !path.to_ascii_lowercase().ends_with(".ecproj")
|
||||
&& let Some(d) = STORE.file(&path)
|
||||
{
|
||||
pool.add_bytes(&path, d);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
//! What the browser keeps between visits: the in-memory side of the persistent store.
|
||||
//!
|
||||
//! The engine's storage traits are synchronous ([`ConfigStore`], [`FileOps`],
|
||||
//! [`effectcraft_engine::Services`]); the browser's storage (the Origin Private File System, or
|
||||
//! IndexedDB where OPFS can't write) is asynchronous. So the app works on a [`Mirror`]: every
|
||||
//! stored entry is loaded into memory before the app starts, reads and writes hit the mirror
|
||||
//! synchronously, and writes queue up as pending operations that the web host flushes to the
|
||||
//! browser in the background (coalesced per key, in order).
|
||||
//!
|
||||
//! Keys are namespaced:
|
||||
//!
|
||||
//! | Key | What |
|
||||
//! |---|---|
|
||||
//! | `config/<name>` | settings (`prefs.json`), shortcut presets, the recovery sentinel, the session snapshot |
|
||||
//! | `files<path>` | the virtual file table: imported media, saved projects, auto-saves (`/EffectCraft Auto-Save/…`) |
|
||||
//!
|
||||
//! Render outputs go into the file table unpersisted (they download instead).
|
||||
//!
|
||||
//! This module is plain Rust (no browser APIs) so it is unit-tested natively.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_engine::config::{ConfigStore, FileOps};
|
||||
|
||||
/// Prefix of configuration entries.
|
||||
pub const CONFIG: &str = "config/";
|
||||
/// Prefix of file-table entries (followed by the absolute path, `files/clip.mp4`).
|
||||
pub const FILES: &str = "files";
|
||||
/// The session snapshot (project + editor state), written while the app runs and restored
|
||||
/// on the next visit.
|
||||
pub const SNAPSHOT: &str = "session.ecproj";
|
||||
/// Its metadata ([`SnapshotMeta`]).
|
||||
pub const SNAPSHOT_META: &str = "session.json";
|
||||
|
||||
/// One stored blob.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Entry {
|
||||
pub data: Arc<[u8]>,
|
||||
/// Milliseconds since the Unix epoch.
|
||||
pub modified: f64,
|
||||
/// Written to the browser's storage (render outputs are not).
|
||||
pub persist: bool,
|
||||
}
|
||||
|
||||
/// A queued write for the browser storage: `data` = `None` deletes the key.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Pending {
|
||||
pub key: String,
|
||||
pub data: Option<Arc<[u8]>>,
|
||||
pub modified: f64,
|
||||
}
|
||||
|
||||
/// The in-memory copy of the store plus the writes not yet flushed.
|
||||
#[derive(Default)]
|
||||
pub struct Mirror {
|
||||
entries: BTreeMap<String, Entry>,
|
||||
/// Keys changed since the last [`Mirror::take_pending`], in first-change order.
|
||||
dirty: Vec<String>,
|
||||
}
|
||||
|
||||
impl Mirror {
|
||||
/// An entry read from the browser's storage at startup (nothing to flush).
|
||||
pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) {
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist: true });
|
||||
}
|
||||
|
||||
pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) {
|
||||
let was_persisted = self.entries.get(key).is_some_and(|e| e.persist);
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist });
|
||||
if persist || was_persisted {
|
||||
self.touch(key);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &str) -> bool {
|
||||
match self.entries.remove(key) {
|
||||
Some(e) => {
|
||||
if e.persist {
|
||||
self.touch(key);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn touch(&mut self, key: &str) {
|
||||
if !self.dirty.iter().any(|k| k == key) {
|
||||
self.dirty.push(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&Entry> {
|
||||
self.entries.get(key)
|
||||
}
|
||||
|
||||
/// Entries under `prefix`: (key, size, modified, persisted).
|
||||
pub fn list(&self, prefix: &str) -> Vec<(String, usize, f64, bool)> {
|
||||
self.entries
|
||||
.range(prefix.to_string()..)
|
||||
.take_while(|(k, _)| k.starts_with(prefix))
|
||||
.map(|(k, e)| (k.clone(), e.data.len(), e.modified, e.persist))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The writes to flush, one per changed key with its latest state (a put, or a delete when
|
||||
/// the key is gone or no longer persisted).
|
||||
pub fn take_pending(&mut self) -> Vec<Pending> {
|
||||
std::mem::take(&mut self.dirty)
|
||||
.into_iter()
|
||||
.map(|key| match self.entries.get(&key) {
|
||||
Some(e) if e.persist => Pending { data: Some(e.data.clone()), modified: e.modified, key },
|
||||
_ => Pending { key, data: None, modified: 0.0 },
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn has_pending(&self) -> bool {
|
||||
!self.dirty.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The key of a file-table path (`/a/b.mov` → `files/a/b.mov`).
|
||||
pub fn file_key(path: &str) -> String {
|
||||
if path.starts_with('/') { format!("{FILES}{path}") } else { format!("{FILES}/{path}") }
|
||||
}
|
||||
|
||||
/// The path of a file-table key.
|
||||
pub fn key_path(key: &str) -> Option<&str> {
|
||||
key.strip_prefix(FILES).filter(|p| p.starts_with('/'))
|
||||
}
|
||||
|
||||
/// The persistent store: a [`Mirror`] and a hook that wakes the flusher after changes.
|
||||
#[derive(Default)]
|
||||
pub struct Store {
|
||||
pub mirror: Mutex<Mirror>,
|
||||
/// Called after every change that needs flushing (the web host schedules a flush).
|
||||
pub on_change: OnceLock<fn()>,
|
||||
/// Clock in milliseconds since the Unix epoch (tests pin it).
|
||||
pub clock: OnceLock<fn() -> f64>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn lock(&self) -> std::sync::MutexGuard<'_, Mirror> {
|
||||
self.mirror.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn now(&self) -> f64 {
|
||||
self.clock.get().map_or(0.0, |f| f())
|
||||
}
|
||||
|
||||
fn changed(&self) {
|
||||
if let Some(f) = self.on_change.get() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- file table
|
||||
|
||||
/// Store a file under `path` (persisted unless `persist` is false: render outputs).
|
||||
pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) {
|
||||
let now = self.now();
|
||||
self.lock().put(&file_key(path), data, now, persist);
|
||||
if persist {
|
||||
self.changed();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn file(&self, path: &str) -> Option<Arc<[u8]>> {
|
||||
self.lock().get(&file_key(path)).map(|e| e.data.clone())
|
||||
}
|
||||
|
||||
pub fn remove_file(&self, path: &str) -> bool {
|
||||
let r = self.lock().remove(&file_key(path));
|
||||
self.changed();
|
||||
r
|
||||
}
|
||||
|
||||
/// Every file: (path, size, modified ms, persisted).
|
||||
pub fn files(&self) -> Vec<(String, usize, f64, bool)> {
|
||||
self.lock().list(FILES).into_iter().filter_map(|(k, n, m, p)| Some((key_path(&k)?.to_string(), n, m, p))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings and friends ([`ConfigStore`]) in the store; auto-saves ([`FileOps`]) in its file
|
||||
/// table, under `/` (untitled projects auto-save to `/EffectCraft Auto-Save/`).
|
||||
pub struct WebConfig {
|
||||
store: Arc<Store>,
|
||||
files: WebFiles,
|
||||
}
|
||||
|
||||
impl WebConfig {
|
||||
pub fn new(store: Arc<Store>) -> WebConfig {
|
||||
WebConfig { files: WebFiles(store.clone()), store }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`FileOps`] on the store's file table.
|
||||
pub struct WebFiles(pub Arc<Store>);
|
||||
|
||||
impl ConfigStore for WebConfig {
|
||||
fn read(&self, name: &str) -> Option<String> {
|
||||
let m = self.store.lock();
|
||||
m.get(&format!("{CONFIG}{name}")).map(|e| String::from_utf8_lossy(&e.data).into_owned())
|
||||
}
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()> {
|
||||
let now = self.store.now();
|
||||
self.store.lock().put(&format!("{CONFIG}{name}"), data.as_bytes().into(), now, true);
|
||||
self.store.changed();
|
||||
Ok(())
|
||||
}
|
||||
fn remove(&self, name: &str) -> std::io::Result<()> {
|
||||
self.store.lock().remove(&format!("{CONFIG}{name}"));
|
||||
self.store.changed();
|
||||
Ok(())
|
||||
}
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
Some(PathBuf::from("/"))
|
||||
}
|
||||
fn files(&self) -> &dyn FileOps {
|
||||
&self.files
|
||||
}
|
||||
}
|
||||
|
||||
fn path_str(p: &Path) -> String {
|
||||
p.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
impl FileOps for WebFiles {
|
||||
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)> {
|
||||
let dir = path_str(dir);
|
||||
let dir = dir.trim_end_matches('/');
|
||||
self.0
|
||||
.files()
|
||||
.into_iter()
|
||||
.filter_map(|(p, _, m, _)| {
|
||||
let (parent, name) = p.rsplit_once('/')?;
|
||||
(parent == dir).then(|| (name.to_string(), std::time::UNIX_EPOCH + std::time::Duration::from_secs_f64(m.max(0.0) / 1000.0)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
self.0.put_file(&path_str(path), data.into(), true);
|
||||
Ok(())
|
||||
}
|
||||
fn remove(&self, path: &Path) -> std::io::Result<()> {
|
||||
if self.0.remove_file(&path_str(path)) { Ok(()) } else { Err(std::io::Error::new(std::io::ErrorKind::NotFound, path_str(path))) }
|
||||
}
|
||||
fn is_file(&self, path: &Path) -> bool {
|
||||
self.0.file(&path_str(path)).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the session snapshot records besides the project.
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SnapshotMeta {
|
||||
/// The project's file (None = untitled).
|
||||
pub path: Option<String>,
|
||||
/// Unsaved changes when the snapshot was taken.
|
||||
pub dirty: bool,
|
||||
/// Viewer, timeline and selection state.
|
||||
pub state: Option<serde_json::Value>,
|
||||
/// Milliseconds since the Unix epoch.
|
||||
pub saved: f64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store() -> Arc<Store> {
|
||||
let s = Arc::new(Store::default());
|
||||
let _ = s.clock.set(|| 1_000.0);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_coalesce_per_key_in_order() {
|
||||
let mut m = Mirror::default();
|
||||
m.put("config/a", Arc::from(&b"1"[..]), 1.0, true);
|
||||
m.put("files/x.png", Arc::from(&b"px"[..]), 2.0, true);
|
||||
m.put("config/a", Arc::from(&b"2"[..]), 3.0, true);
|
||||
let p = m.take_pending();
|
||||
assert_eq!(p.len(), 2);
|
||||
assert_eq!((p[0].key.as_str(), p[0].data.as_deref()), ("config/a", Some(&b"2"[..])));
|
||||
assert_eq!(p[0].modified, 3.0);
|
||||
assert_eq!(p[1].key, "files/x.png");
|
||||
assert!(!m.has_pending() && m.take_pending().is_empty());
|
||||
// Delete after put: one delete.
|
||||
m.put("files/y", Arc::from(&b"y"[..]), 4.0, true);
|
||||
m.remove("files/y");
|
||||
assert_eq!(m.take_pending(), vec![Pending { key: "files/y".into(), data: None, modified: 0.0 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpersisted_entries_never_flush_and_loaded_ones_are_clean() {
|
||||
let mut m = Mirror::default();
|
||||
m.load("files/old.mov", Arc::from(&b"m"[..]), 5.0);
|
||||
m.put("files/render.gif", Arc::from(&b"g"[..]), 6.0, false);
|
||||
assert!(!m.has_pending());
|
||||
assert!(!m.remove("files/none"));
|
||||
m.remove("files/render.gif");
|
||||
assert!(!m.has_pending());
|
||||
// Overwriting a persisted file with an unpersisted one deletes the stored copy.
|
||||
m.put("files/old.mov", Arc::from(&b"n"[..]), 7.0, false);
|
||||
assert_eq!(m.take_pending()[0].data, None);
|
||||
assert_eq!(m.list("files").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_and_paths_round_trip() {
|
||||
assert_eq!(file_key("/a b/c.mov"), "files/a b/c.mov");
|
||||
assert_eq!(file_key("c.mov"), "files/c.mov");
|
||||
assert_eq!(key_path("files/a b/c.mov"), Some("/a b/c.mov"));
|
||||
assert_eq!(key_path("config/prefs.json"), None);
|
||||
let s = store();
|
||||
s.put_file("/clip.mp4", Arc::from(&b"abc"[..]), true);
|
||||
s.put_file("/out.gif", Arc::from(&b"g"[..]), false);
|
||||
let f = s.files();
|
||||
assert_eq!(f, vec![("/clip.mp4".to_string(), 3, 1000.0, true), ("/out.gif".to_string(), 1, 1000.0, false)]);
|
||||
assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_store_and_file_ops() {
|
||||
let s = store();
|
||||
let c = WebConfig::new(s.clone());
|
||||
assert_eq!(c.read("prefs.json"), None);
|
||||
c.write("prefs.json", "{\"a\":1}").unwrap();
|
||||
assert_eq!(c.read("prefs.json").as_deref(), Some("{\"a\":1}"));
|
||||
c.remove("prefs.json").unwrap();
|
||||
assert_eq!(c.read("prefs.json"), None);
|
||||
// Config entries are not files.
|
||||
c.write("session.lock", "{}").unwrap();
|
||||
assert!(s.files().is_empty());
|
||||
let fs = c.files();
|
||||
fs.write(Path::new("/EffectCraft Auto-Save/P auto-save 1.ecproj"), b"{}").unwrap();
|
||||
fs.write(Path::new("/EffectCraft Auto-Save/P auto-save 2.ecproj"), b"{}").unwrap();
|
||||
fs.write(Path::new("/other.ecproj"), b"{}").unwrap();
|
||||
let mut names: Vec<String> = fs.list(Path::new("/EffectCraft Auto-Save")).into_iter().map(|e| e.0).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["P auto-save 1.ecproj", "P auto-save 2.ecproj"]);
|
||||
assert!(fs.is_file(Path::new("/other.ecproj")));
|
||||
fs.remove(Path::new("/other.ecproj")).unwrap();
|
||||
assert!(!fs.is_file(Path::new("/other.ecproj")));
|
||||
assert!(fs.remove(Path::new("/other.ecproj")).is_err());
|
||||
let pending = s.lock().take_pending();
|
||||
let keys: Vec<&str> = pending.iter().map(|p| p.key.as_str()).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"config/prefs.json",
|
||||
"config/session.lock",
|
||||
"files/EffectCraft Auto-Save/P auto-save 1.ecproj",
|
||||
"files/EffectCraft Auto-Save/P auto-save 2.ecproj",
|
||||
"files/other.ecproj"
|
||||
]
|
||||
);
|
||||
assert_eq!(pending[0].data, None, "removed");
|
||||
assert_eq!(pending[4].data, None, "removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_and_recovery_run_in_the_browser_store() {
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
let s = store();
|
||||
let mut a = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() };
|
||||
a.load_settings();
|
||||
assert!(a.begin_recovery().is_none());
|
||||
a.execute("file.newProject", json!({})).unwrap();
|
||||
a.execute("comp.new", json!({"name": "C"})).unwrap();
|
||||
// Untitled: the auto-save lands in the store's file table.
|
||||
let p = a.autosave_now().unwrap();
|
||||
assert_eq!(p, "/EffectCraft Auto-Save/Untitled Project auto-save 1.ecproj");
|
||||
assert!(s.file(&p).is_some());
|
||||
// The tab closes without a clean exit: the next visit offers the auto-save.
|
||||
let mut b = Session { config: Some(Arc::new(WebConfig::new(s.clone()))), ..Default::default() };
|
||||
b.load_settings();
|
||||
let r = b.begin_recovery().expect("recovery");
|
||||
assert!(r.autosave.is_some());
|
||||
// Increment and Save sees the store's files.
|
||||
let fs = WebConfig::new(s.clone());
|
||||
fs.files().write(Path::new("/A.ecproj"), b"{}").unwrap();
|
||||
let next = effectcraft_engine::autosave::increment_path("/A.ecproj", |p| fs.files().is_file(Path::new(p)));
|
||||
assert_eq!(next, "/A 2.ecproj");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_meta_round_trips() {
|
||||
let m = SnapshotMeta { path: Some("/p.ecproj".into()), dirty: true, state: Some(serde_json::json!({"activeComp": 3})), saved: 12.5 };
|
||||
let t = serde_json::to_string(&m).unwrap();
|
||||
assert_eq!(serde_json::from_str::<SnapshotMeta>(&t).unwrap(), m);
|
||||
assert_eq!(serde_json::from_str::<SnapshotMeta>("{}").unwrap(), SnapshotMeta::default());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
//! Renders and analyses in Web Workers ([`effectcraft_engine::offload`]).
|
||||
//!
|
||||
//! **Page side** — [`WorkerOffload`], the session's [`Offload`]: serializes the job, ships the
|
||||
//! footage files it reads and the request to a worker from the host's pool (`js/host.js`
|
||||
//! `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.
|
||||
//!
|
||||
//! **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
|
||||
//! from memory, expressions, the exporter) runs it blocking and posts replies as it goes. Each
|
||||
//! worker has its own memory: no shared-memory threads, so this works on stable Rust.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::offload::{Inbox, Offload, WorkerReply, WorkerRequest};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(module = "/js/host.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = workerRun)]
|
||||
fn worker_run(id: f64, json: &str, files: js_sys::Array, base: &str, on_message: &js_sys::Function);
|
||||
#[wasm_bindgen(js_name = workerCancel)]
|
||||
fn worker_cancel(id: f64);
|
||||
#[wasm_bindgen(js_name = workerStats)]
|
||||
pub fn stats() -> JsValue;
|
||||
}
|
||||
|
||||
type OnMessage = Closure<dyn FnMut(String, JsValue, JsValue, JsValue)>;
|
||||
|
||||
thread_local! {
|
||||
/// Message handlers of running jobs (dropped when the job ends).
|
||||
static HANDLERS: RefCell<HashMap<u64, OnMessage>> = RefCell::new(HashMap::new());
|
||||
/// The worker's session and its media pool.
|
||||
static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
|
||||
static POOL: RefCell<Option<Arc<effectcraft_media::MediaPool>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The page's offload: one worker per running job (idle workers are reused).
|
||||
pub struct WorkerOffload;
|
||||
|
||||
impl Offload for WorkerOffload {
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
|
||||
let id = req.id;
|
||||
let files = js_sys::Array::new();
|
||||
for p in &req.files {
|
||||
if let Some(d) = crate::files::get(p) {
|
||||
files.push(&js_sys::Array::of2(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..])));
|
||||
}
|
||||
}
|
||||
let json = serde_json::to_string(&req).map_err(|e| e.to_string())?;
|
||||
let on: OnMessage = Closure::new(move |kind: String, json: JsValue, path: JsValue, bytes: JsValue| {
|
||||
match kind.as_str() {
|
||||
"reply" => {
|
||||
let Some(t) = json.as_string() else { return };
|
||||
match serde_json::from_str::<WorkerReply>(&t) {
|
||||
Ok(r) => {
|
||||
let done = matches!(r, WorkerReply::Done);
|
||||
inbox.push(r);
|
||||
if done {
|
||||
end(id);
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("worker reply: {e}"),
|
||||
}
|
||||
}
|
||||
"file" => {
|
||||
if let (Some(p), Ok(b)) = (path.as_string(), bytes.dyn_into::<js_sys::Uint8Array>()) {
|
||||
crate::files::add_output(&p, b.to_vec().into());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
inbox.push(WorkerReply::Failed { error: json.as_string().unwrap_or_else(|| "worker failed".into()) });
|
||||
inbox.push(WorkerReply::Done);
|
||||
end(id);
|
||||
}
|
||||
}
|
||||
crate::repaint();
|
||||
});
|
||||
worker_run(id as f64, &json, files, &crate::page_url(""), on.as_ref().unchecked_ref());
|
||||
HANDLERS.with(|h| h.borrow_mut().insert(id, on));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cancel(&self, id: u64) {
|
||||
worker_cancel(id as f64);
|
||||
end(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a job's handler (after the current call returns).
|
||||
fn end(id: u64) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
HANDLERS.with(|h| h.borrow_mut().remove(&id));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- worker side
|
||||
|
||||
fn post(msg: &JsValue, transfer: Option<&js_sys::Array>) {
|
||||
let scope: web_sys::DedicatedWorkerGlobalScope = js_sys::global().unchecked_into();
|
||||
let r = match transfer {
|
||||
Some(t) => scope.post_message_with_transfer(msg, t),
|
||||
None => scope.post_message(msg),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
log::warn!("worker: postMessage: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/// Set up the worker's engine session (called once by `web/worker.js`).
|
||||
#[wasm_bindgen(js_name = workerInit)]
|
||||
pub fn worker_init() {
|
||||
crate::set_worker();
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
let pool = Arc::new(effectcraft_media::MediaPool::new());
|
||||
POOL.with(|p| *p.borrow_mut() = Some(pool.clone()));
|
||||
// Rendered files go back to the page (transferred, not copied).
|
||||
let sink: Arc<effectcraft_export::Sink> = Arc::new(|path: &str, data: Vec<u8>| {
|
||||
let bytes = js_sys::Uint8Array::from(&data[..]);
|
||||
let buf = bytes.buffer();
|
||||
post(&obj(&[("type", "file".into()), ("path", path.into()), ("bytes", buf.clone().into())]), Some(&js_sys::Array::of1(&buf)));
|
||||
});
|
||||
let s = Session {
|
||||
services: Arc::new(crate::files::WebServices),
|
||||
footage: pool.clone(),
|
||||
importer: Some(Arc::new(crate::files::WebImporter { pool })),
|
||||
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
||||
expr_check: Some(effectcraft_expr::check_syntax),
|
||||
exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(sink) })),
|
||||
..Default::default()
|
||||
};
|
||||
SESSION.with(|c| *c.borrow_mut() = Some(s));
|
||||
}
|
||||
|
||||
/// A footage file for the jobs that follow.
|
||||
#[wasm_bindgen(js_name = workerFile)]
|
||||
pub fn worker_file(path: String, bytes: js_sys::Uint8Array) {
|
||||
let data: Arc<[u8]> = bytes.to_vec().into();
|
||||
crate::files::STORE.put_file(&path, data.clone(), false);
|
||||
POOL.with(|p| {
|
||||
if let Some(p) = p.borrow().as_ref() {
|
||||
p.add_bytes(&path, data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run one job (a JSON [`WorkerRequest`]) and post its replies.
|
||||
#[wasm_bindgen(js_name = workerJob)]
|
||||
pub fn worker_job(json: String) {
|
||||
let send: effectcraft_engine::offload::Post = Rc::new(|r: WorkerReply| {
|
||||
let done = matches!(r, WorkerReply::Done);
|
||||
let t = serde_json::to_string(&r).unwrap_or_default();
|
||||
post(&obj(&[("type", "reply".into()), ("json", t.into()), ("done", done.into())]), None);
|
||||
});
|
||||
let req: WorkerRequest = match serde_json::from_str(&json) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
send(WorkerReply::Failed { error: format!("bad request: {e}") });
|
||||
send(WorkerReply::Done);
|
||||
return;
|
||||
}
|
||||
};
|
||||
SESSION.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let Some(s) = c.as_mut() else {
|
||||
send(WorkerReply::Failed { error: "worker not initialised".into() });
|
||||
send(WorkerReply::Done);
|
||||
return;
|
||||
};
|
||||
effectcraft_engine::offload::run_request(s, req, &send);
|
||||
});
|
||||
}
|
||||
|
|
@ -4,10 +4,15 @@
|
|||
// cargo xtask web --serve 8765 & # build + serve <target>/web/dist
|
||||
// node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out /tmp/ec-web
|
||||
//
|
||||
// Steps: load (timing), demo comp in the viewer (screenshot, viewer pixels), `render.frame`
|
||||
// through `window.effectcraft`, Render Queue GIF and PNG-sequence (.zip) exports (downloads), File ▸ Save As (download),
|
||||
// re-opening the saved project through `effectcraft.addFile`. Prints a JSON report; exits non-zero
|
||||
// on failure. `--headed` shows the browser.
|
||||
// Steps: load (timing), the GPU path (WebGPU → GPU compositor, viewer frames on the GPU), demo
|
||||
// comp in the viewer (screenshot, viewer pixels), `render.frame` through `window.effectcraft`,
|
||||
// Render Queue GIF and PNG-sequence (.zip) exports (downloads), a background Render Queue job in
|
||||
// a Web Worker while the page stays responsive (event-loop gaps measured), audio (the
|
||||
// AudioContext starts on a user gesture; preview playback feeds it), File ▸ Save As (download),
|
||||
// re-opening the saved project through `effectcraft.addFile`, persistence across a reload (the
|
||||
// session, imported media, settings, a project saved to browser storage, Open Recent) and an
|
||||
// offline reload through the service worker. Prints a JSON report; exits non-zero on failure.
|
||||
// `--headed` shows the browser.
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
|
@ -85,6 +90,22 @@ async function js(expr, timeout = 300000) {
|
|||
return r.result.value;
|
||||
}
|
||||
|
||||
const check = (cond, msg) => {
|
||||
if (!cond) throw new Error(`check failed: ${msg}`);
|
||||
};
|
||||
|
||||
// Wait until the app runs (after a navigation or reload).
|
||||
async function ready(ms = 180000) {
|
||||
await until("!!(window.effectcraftLoad && (window.effectcraftLoad.readyMs || window.effectcraftLoad.error))", ms);
|
||||
const load = await js("window.effectcraftLoad");
|
||||
if (load.error) throw new Error(load.error);
|
||||
return load;
|
||||
}
|
||||
|
||||
async function idle() {
|
||||
await until(`effectcraft.execute("renderQueue.list", {}).then(r => !r.rendering)`, 300000, 200);
|
||||
}
|
||||
|
||||
async function shot(name) {
|
||||
const r = await send("Page.captureScreenshot", { format: "png" });
|
||||
const p = join(out, `${name}.png`);
|
||||
|
|
@ -121,11 +142,11 @@ try {
|
|||
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
|
||||
const t0 = Date.now();
|
||||
await send("Page.navigate", { url });
|
||||
await until("!!(window.effectcraftLoad && (window.effectcraftLoad.readyMs || window.effectcraftLoad.error))", 180000);
|
||||
report.load = await js("window.effectcraftLoad");
|
||||
report.load = await ready();
|
||||
report.load.wallMs = Date.now() - t0;
|
||||
if (report.load.error) throw new Error(report.load.error);
|
||||
report.info = await js("effectcraft.info()");
|
||||
// Storage: OPFS where the browser writes it (Chrome does), else IndexedDB.
|
||||
check(["opfs", "indexeddb"].includes(report.info.storage?.backend), `storage backend ${JSON.stringify(report.info.storage)}`);
|
||||
|
||||
// The demo comp in the viewer: wait for the first viewer frame (renderMs > 0).
|
||||
const ms = await until("effectcraft.inspect().then(i => i.renderMs > 0)", 120000, 500);
|
||||
|
|
@ -133,6 +154,15 @@ try {
|
|||
const insp = await js("effectcraft.inspect()");
|
||||
report.steps.viewer = { waitMs: ms, renderMs: insp.renderMs, activeComp: insp.activeComp, window: insp.window, screenshot: await shot("01-demo") };
|
||||
|
||||
// GPU: with WebGPU, eframe runs on it and the compositor renders viewer frames on the GPU
|
||||
// (no readback); without it, WebGL2 / CPU.
|
||||
const info = await js("effectcraft.info()");
|
||||
report.steps.gpu = { webgpu: info.webgpu, backend: info.backend, ...info.gpu };
|
||||
if (info.webgpu && info.backend === "BrowserWebGpu") {
|
||||
check(info.gpu && info.gpu.compositor, "GPU compositor on WebGPU");
|
||||
check(info.gpu.viewerOnGpu, "viewer frames on the GPU");
|
||||
}
|
||||
|
||||
// Comp pixels through the API.
|
||||
const f = await js("effectcraft.renderFrame({max_side: 480}).then(r => ({w: r.width, h: r.height, pngBytes: atob(r.png).length}))");
|
||||
report.steps.renderFrame = f;
|
||||
|
|
@ -145,12 +175,74 @@ try {
|
|||
await js(`effectcraft.execute("renderQueue.render", {})`);
|
||||
const gif = await waitDownload((n) => n.endsWith(".gif"));
|
||||
report.steps.gifExport = { ...gif, ms: Date.now() - rqT };
|
||||
await idle();
|
||||
|
||||
// An image sequence arrives as one .zip.
|
||||
await js(`effectcraft.execute("renderQueue.setRender", {index: 1, render: false})`);
|
||||
await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "png", output: "seq_[####].png", resolution: 0.125, timeSpan: "custom", start: 0, end: 0.2}})`);
|
||||
await js(`effectcraft.execute("renderQueue.render", {})`);
|
||||
report.steps.pngSequence = await waitDownload((n) => n.endsWith(".zip"));
|
||||
await idle();
|
||||
|
||||
// A background render (`wait: false`, what the Render button does) runs in a Web Worker: the
|
||||
// page keeps its event loop. Measure the longest gap between 10 ms timers while it renders.
|
||||
await js(`effectcraft.execute("renderQueue.setRender", {index: 2, render: false})`);
|
||||
await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", output: "bg_[compName].gif", resolution: 0.5, timeSpan: "custom", start: 0, end: 4}})`);
|
||||
report.steps.backgroundRender = await js(`(async () => {
|
||||
const t0 = performance.now();
|
||||
const r = await effectcraft.execute("renderQueue.render", {wait: false});
|
||||
const callMs = performance.now() - t0;
|
||||
let maxGapMs = 0, last = performance.now(), ticks = 0, progress = 0;
|
||||
for (;;) {
|
||||
await new Promise((res) => setTimeout(res, 10));
|
||||
const now = performance.now();
|
||||
maxGapMs = Math.max(maxGapMs, now - last);
|
||||
last = now;
|
||||
if (++ticks % 5) continue;
|
||||
const l = await effectcraft.execute("renderQueue.list", {});
|
||||
if (l.progress) progress = Math.max(progress, l.progress.done);
|
||||
if (!l.rendering) break;
|
||||
}
|
||||
return {startedRendering: r.rendering, callMs, renderMs: performance.now() - t0, maxGapMs, ticks, progressFrames: progress, workers: effectcraft.info().workers};
|
||||
})()`);
|
||||
const bg = report.steps.backgroundRender;
|
||||
bg.download = await waitDownload((n) => n.startsWith("bg_") && n.endsWith(".gif"));
|
||||
check(bg.startedRendering, "render started in the background");
|
||||
check(bg.maxGapMs < 400, `event loop blocked ${bg.maxGapMs} ms during the render`);
|
||||
check(bg.progressFrames > 0, "progress reported while rendering");
|
||||
|
||||
// Audio: the AudioContext starts on a user gesture.
|
||||
report.steps.audio = { before: (await js("effectcraft.info()")).audio };
|
||||
check(report.steps.audio.before.state !== "running", "audio context not running before a gesture");
|
||||
// (a click in the window's bottom-right corner, the status bar: modifier keys alone don't
|
||||
// count as user activation)
|
||||
await send("Input.dispatchMouseEvent", { type: "mousePressed", x: 1590, y: 995, button: "left", clickCount: 1 });
|
||||
await send("Input.dispatchMouseEvent", { type: "mouseReleased", x: 1590, y: 995, button: "left", clickCount: 1 });
|
||||
await until("effectcraft.info().audio.state === 'running'", 10000);
|
||||
report.steps.audio.afterGesture = (await js("effectcraft.info()")).audio;
|
||||
// A 2 s tone in the comp; preview playback feeds the output and the meters.
|
||||
await js(`(async () => {
|
||||
const rate = 48000, n = 2 * rate, b = new ArrayBuffer(44 + n * 4), v = new DataView(b);
|
||||
const w = (o, t) => [...t].forEach((c, i) => v.setUint8(o + i, c.charCodeAt(0)));
|
||||
w(0, "RIFF"); v.setUint32(4, 36 + n * 4, true); w(8, "WAVE"); w(12, "fmt "); v.setUint32(16, 16, true);
|
||||
v.setUint16(20, 1, true); v.setUint16(22, 2, true); v.setUint32(24, rate, true); v.setUint32(28, rate * 4, true);
|
||||
v.setUint16(32, 4, true); v.setUint16(34, 16, true); w(36, "data"); v.setUint32(40, n * 4, true);
|
||||
for (let i = 0; i < n; i++) { const x = Math.round(Math.sin(2 * Math.PI * 440 * i / rate) * 0.5 * 32767); v.setInt16(44 + 4 * i, x, true); v.setInt16(46 + 4 * i, x, true); }
|
||||
return effectcraft.addFile(new File([b], "tone.wav"));
|
||||
})()`);
|
||||
await until(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => true, () => false)`, 20000, 300);
|
||||
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
|
||||
await js(`effectcraft.execute("time.set", {time: 0})`);
|
||||
await js(`effectcraft.request("ui.playback", {action: "play"})`);
|
||||
await sleep(1500);
|
||||
const pb = await js(`effectcraft.request("ui.playback", {action: "status"})`);
|
||||
const au = (await js("effectcraft.info()")).audio;
|
||||
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
|
||||
report.steps.audio.playing = { playback: pb, output: au };
|
||||
check(pb.audio, "audio preview running");
|
||||
check(au.posted > 0.5 * au.sampleRate, `audio fed (${au.posted} frames)`);
|
||||
check(au.played > 0, "audio clock advancing");
|
||||
check(Math.max(...(pb.levelsDb || [-99])) > -20, `meters (${JSON.stringify(pb.levelsDb)})`);
|
||||
|
||||
// Save As → .ecproj download; reopen it.
|
||||
await js(`effectcraft.execute("file.saveAs", {path: "/smoke.ecproj"})`);
|
||||
|
|
@ -162,6 +254,60 @@ try {
|
|||
if (!comps) throw new Error("reopened project has no active comp");
|
||||
report.steps.reopen = { ...opened, activeComp: comps };
|
||||
report.steps.final = { screenshot: await shot("02-after") };
|
||||
|
||||
// Persistence: the session, imported media, settings and a project saved to browser storage
|
||||
// survive a reload; File ▸ Open Recent reopens the project.
|
||||
await js(`effectcraft.execute("layer.newSolid", {name: "Persisted Solid", color: "#22aa66"})`);
|
||||
await js(`effectcraft.execute("prefs.set", {key: "general.recentItems", value: 7})`);
|
||||
const saved = await js(`effectcraft.saveToBrowser("/persist-test.ecproj")`);
|
||||
await js(`effectcraft.execute("layer.newSolid", {name: "Unsaved Solid"})`);
|
||||
await sleep(1500); // the session snapshot is taken at most every second
|
||||
await js("effectcraft.flush()");
|
||||
const before = await js("effectcraft.listStored()");
|
||||
const tr = Date.now();
|
||||
await send("Page.reload", {});
|
||||
await sleep(500);
|
||||
const load2 = await ready();
|
||||
const after = await js("effectcraft.listStored()");
|
||||
const info2 = await js("effectcraft.info()");
|
||||
const layers = await js(`effectcraft.execute("comp.info", {}).then(c => c.layers.map(l => l.name))`);
|
||||
const recentItems = await js(`effectcraft.execute("prefs.get", {key: "general.recentItems"})`);
|
||||
const rec = await js(`effectcraft.execute("file.recoveryInfo", {})`);
|
||||
const paths = after.files.map((f) => f.path);
|
||||
report.steps.persistence = { saved, reloadMs: Date.now() - tr, load: load2, restored: info2.restored, storage: info2.storage, layers, recentItems, recent: rec.recentProjects, stored: paths, usage: after.usage, filesBefore: before.files.length };
|
||||
check(info2.restored, "session restored after reload");
|
||||
check(layers.includes("Persisted Solid") && layers.includes("Unsaved Solid"), `layers after reload: ${layers}`);
|
||||
check(paths.includes("/tone.wav") && paths.includes("/persist-test.ecproj"), `stored files: ${paths}`);
|
||||
check(recentItems === 7, `setting after reload: ${recentItems}`);
|
||||
check(rec.recentProjects[0] === "/persist-test.ecproj", `recent projects: ${rec.recentProjects}`);
|
||||
await js(`effectcraft.execute("file.openRecent", {index: 0})`);
|
||||
const reopened = await js(`effectcraft.execute("comp.info", {}).then(c => c.layers.map(l => l.name))`);
|
||||
check(reopened.includes("Persisted Solid") && !reopened.includes("Unsaved Solid"), `Open Recent: ${reopened}`);
|
||||
// The imported tone is still in the project and decodes from storage after the reload.
|
||||
const tone = await js(`effectcraft.execute("layer.addItem", {item: "tone.wav", time: 0}).then(() => "ok", (e) => String(e))`);
|
||||
check(tone === "ok", `tone.wav after reload: ${tone}`);
|
||||
report.steps.persistence.openRecent = reopened;
|
||||
|
||||
// PWA: manifest, service worker in control, and an offline reload.
|
||||
const pwa = await js(`(async () => {
|
||||
const m = await (await fetch("manifest.webmanifest")).json();
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
const keys = await caches.keys();
|
||||
const cached = keys.length ? (await (await caches.open(keys[0])).keys()).length : 0;
|
||||
return {manifest: m.name, icons: m.icons.length, display: m.display, sw: !!(reg && reg.active), controlled: !!navigator.serviceWorker.controller, caches: keys, cached};
|
||||
})()`);
|
||||
check(pwa.sw && pwa.cached > 4, `service worker: ${JSON.stringify(pwa)}`);
|
||||
await send("Network.enable", {});
|
||||
await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
|
||||
await send("Page.reload", {});
|
||||
await sleep(500);
|
||||
const offline = await ready();
|
||||
const info3 = await js("effectcraft.info()");
|
||||
await send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
|
||||
report.steps.pwa = { ...pwa, offlineLoad: offline, offlineControlled: info3.serviceWorker, crossOriginIsolated: info3.crossOriginIsolated };
|
||||
check(info3.serviceWorker, "offline reload served by the service worker");
|
||||
check(info3.crossOriginIsolated, "cross-origin isolated under the service worker");
|
||||
report.steps.final2 = { screenshot: await shot("03-offline") };
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
report.error = String(e && e.stack || e);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
// EffectCraft audio output (AudioWorklet): plays interleaved stereo blocks posted by the page
|
||||
// (src/audio.rs feeds them from the preview mixdown). Running dry plays silence, reported back
|
||||
// as underrun frames.
|
||||
class EffectcraftOutput extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.queue = [];
|
||||
this.offset = 0;
|
||||
this.stopped = false;
|
||||
this.port.onmessage = (e) => {
|
||||
if (e.data === "stop") this.stopped = true;
|
||||
else this.queue.push(e.data);
|
||||
};
|
||||
}
|
||||
|
||||
process(_inputs, outputs) {
|
||||
const out = outputs[0];
|
||||
const l = out[0];
|
||||
const r = out[1] ?? out[0];
|
||||
let i = 0;
|
||||
while (i < l.length && this.queue.length) {
|
||||
const b = this.queue[0];
|
||||
const n = Math.min(l.length - i, (b.length - this.offset) >> 1);
|
||||
for (let k = 0; k < n; k++) {
|
||||
l[i + k] = b[this.offset + 2 * k];
|
||||
r[i + k] = b[this.offset + 2 * k + 1];
|
||||
}
|
||||
i += n;
|
||||
this.offset += 2 * n;
|
||||
if (this.offset >= b.length) {
|
||||
this.queue.shift();
|
||||
this.offset = 0;
|
||||
}
|
||||
}
|
||||
if (i < l.length) {
|
||||
l.fill(0, i);
|
||||
r.fill(0, i);
|
||||
if (!this.stopped) this.port.postMessage({ underrun: l.length - i });
|
||||
}
|
||||
return !this.stopped;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor("effectcraft-output", EffectcraftOutput);
|
||||
|
|
@ -4,8 +4,12 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#1b1c1f">
|
||||
<meta name="description" content="EffectCraft: motion graphics and visual effects in the browser">
|
||||
<title>EffectCraft</title>
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="icon-256.png">
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #1b1c1f; }
|
||||
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; outline: none; }
|
||||
|
|
@ -46,10 +50,34 @@
|
|||
}
|
||||
return ec.addFile(name, bytes);
|
||||
},
|
||||
info: () => ({ ...ec.info(), crossOriginIsolated: self.crossOriginIsolated, load: window.effectcraftLoad }),
|
||||
info: () => ({
|
||||
...ec.info(),
|
||||
crossOriginIsolated: self.crossOriginIsolated,
|
||||
webgpu: !!navigator.gpu,
|
||||
serviceWorker: !!(navigator.serviceWorker && navigator.serviceWorker.controller),
|
||||
load: window.effectcraftLoad,
|
||||
}),
|
||||
// Browser storage (OPFS / IndexedDB): save without downloading, list, remove, wait for writes.
|
||||
saveToBrowser: (path) => ec.saveToBrowser(path ?? undefined),
|
||||
listStored: () => ec.listStored(),
|
||||
removeStored: (path) => ec.removeStored(path),
|
||||
flush: () => ec.flush(),
|
||||
};
|
||||
// Offline start and install (PWA): the service worker caches the app's files.
|
||||
if ("serviceWorker" in navigator && !new URLSearchParams(location.search).has("nosw")) {
|
||||
navigator.serviceWorker.register("sw.js").catch((e) => console.warn("service worker:", e));
|
||||
}
|
||||
try {
|
||||
await init();
|
||||
// Compile once: the page and its render workers instantiate the same module.
|
||||
const wasmUrl = new URL("effectcraft_web_bg.wasm", location.href);
|
||||
let module;
|
||||
try {
|
||||
module = await WebAssembly.compileStreaming(fetch(wasmUrl));
|
||||
} catch {
|
||||
module = await WebAssembly.compile(await (await fetch(wasmUrl)).arrayBuffer());
|
||||
}
|
||||
window.effectcraftModule = module;
|
||||
await init({ module_or_path: module });
|
||||
window.effectcraftLoad.wasmMs = performance.now() - t0;
|
||||
await ec.start("effectcraft_canvas");
|
||||
window.effectcraftLoad.readyMs = performance.now() - t0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "EffectCraft",
|
||||
"short_name": "EffectCraft",
|
||||
"description": "Motion graphics and visual effects compositor, in the browser",
|
||||
"id": "./",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#1b1c1f",
|
||||
"theme_color": "#1b1c1f",
|
||||
"categories": ["graphics", "photo", "productivity"],
|
||||
"icons": [
|
||||
{ "src": "favicon.svg", "sizes": "any", "type": "image/svg+xml" },
|
||||
{ "src": "icon-256.png", "sizes": "256x256", "type": "image/png" },
|
||||
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// EffectCraft service worker: the app works offline once loaded, and installs as an app.
|
||||
//
|
||||
// `cargo xtask web` fills in the build's file list and version. Every file is precached into a
|
||||
// cache named after the version, served cache-first (responses keep the server's headers, so
|
||||
// the page stays cross-origin isolated: COOP/COEP), and old versions are dropped once the new
|
||||
// one activates. Anything else same-origin is fetched from the network and cached for next time.
|
||||
const VERSION = "__EC_VERSION__";
|
||||
const FILES = __EC_FILES__;
|
||||
const CACHE = `effectcraft-${VERSION}`;
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE)
|
||||
.then((c) => c.addAll(FILES.map((f) => new Request(f, { cache: "reload" }))))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k.startsWith("effectcraft-") && k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
if (req.method !== "GET" || url.origin !== self.location.origin) return;
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
// The page itself regardless of its query (?empty, ?demo…).
|
||||
const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
|
||||
const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" });
|
||||
if (hit) return hit;
|
||||
const res = await fetch(req);
|
||||
if (res.ok && res.type === "basic") cache.put(req, res.clone());
|
||||
return res;
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// 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.
|
||||
import init, * as ec from "./effectcraft_web.js";
|
||||
|
||||
let ready = null;
|
||||
self.onmessage = async (e) => {
|
||||
const m = e.data;
|
||||
if (m.type === "init") {
|
||||
ready = init({ module_or_path: m.module ?? new URL("effectcraft_web_bg.wasm", m.base) }).then(() => ec.workerInit());
|
||||
await ready;
|
||||
self.postMessage({ type: "ready" });
|
||||
return;
|
||||
}
|
||||
await ready;
|
||||
if (m.type === "file") ec.workerFile(m.path, m.bytes);
|
||||
else if (m.type === "job") ec.workerJob(m.json);
|
||||
};
|
||||
|
|
@ -13,7 +13,7 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::ConfigStore;
|
||||
use crate::config::{ConfigStore, FileOps, StdFiles};
|
||||
use crate::prefs::Prefs;
|
||||
|
||||
/// Folder created next to a project for its auto-saves.
|
||||
|
|
@ -68,16 +68,12 @@ pub fn folder(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) ->
|
|||
|
||||
/// Existing auto-saves of `stem` in `dir`: (slot, path, modified).
|
||||
pub fn existing(dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
let mut v: Vec<_> = rd
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let n = slot_of(stem, &name)?;
|
||||
let m = e.metadata().ok()?.modified().ok()?;
|
||||
Some((n, e.path(), m))
|
||||
})
|
||||
.collect();
|
||||
existing_in(&StdFiles, dir, stem)
|
||||
}
|
||||
|
||||
/// [`existing`] through `fs`.
|
||||
pub fn existing_in(fs: &dyn FileOps, dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
|
||||
let mut v: Vec<_> = fs.list(dir).into_iter().filter_map(|(name, m)| Some((slot_of(stem, &name)?, dir.join(&name), m))).collect();
|
||||
v.sort_by_key(|x| x.0);
|
||||
v
|
||||
}
|
||||
|
|
@ -95,18 +91,29 @@ pub fn next_slot(existing: &[(u32, PathBuf, std::time::SystemTime)], last: Optio
|
|||
|
||||
/// Write one auto-save of `json` for the project at `project` and rotate. Returns its path.
|
||||
pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option<u32>, json: &str) -> std::io::Result<(PathBuf, u32)> {
|
||||
write_in(&StdFiles, prefs, project, fallback, last_slot, json)
|
||||
}
|
||||
|
||||
/// [`write`] through `fs`.
|
||||
pub fn write_in(
|
||||
fs: &dyn FileOps,
|
||||
prefs: &Prefs,
|
||||
project: Option<&str>,
|
||||
fallback: Option<&Path>,
|
||||
last_slot: Option<u32>,
|
||||
json: &str,
|
||||
) -> std::io::Result<(PathBuf, u32)> {
|
||||
let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let stem = project_stem(project);
|
||||
let max = prefs.auto_save.max_versions.max(1);
|
||||
let have = existing(&dir, &stem);
|
||||
let have = existing_in(fs, &dir, &stem);
|
||||
let slot = next_slot(&have, last_slot, max);
|
||||
let path = dir.join(file_name(&stem, slot));
|
||||
crate::config::atomic_write(&path, json.as_bytes())?;
|
||||
fs.write(&path, json.as_bytes())?;
|
||||
// Max versions lowered: drop slots above it.
|
||||
for (n, p, _) in have {
|
||||
if n > max {
|
||||
let _ = std::fs::remove_file(p);
|
||||
let _ = fs.remove(&p);
|
||||
}
|
||||
}
|
||||
Ok((path, slot))
|
||||
|
|
@ -114,8 +121,13 @@ pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last
|
|||
|
||||
/// The newest auto-save of a project, if any.
|
||||
pub fn latest(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
|
||||
latest_in(&StdFiles, prefs, project, fallback)
|
||||
}
|
||||
|
||||
/// [`latest`] through `fs`.
|
||||
pub fn latest_in(fs: &dyn FileOps, prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
|
||||
let dir = folder(prefs, project, fallback)?;
|
||||
existing(&dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
|
||||
existing_in(fs, &dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
|
||||
}
|
||||
|
||||
/// What the crash-recovery sentinel records.
|
||||
|
|
@ -151,8 +163,8 @@ pub fn begin(store: &dyn ConfigStore, prefs: &Prefs) -> Option<Recovery> {
|
|||
let autosave = s
|
||||
.autosave
|
||||
.clone()
|
||||
.filter(|p| Path::new(p).is_file())
|
||||
.or_else(|| latest(prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
|
||||
.filter(|p| store.files().is_file(Path::new(p)))
|
||||
.or_else(|| latest_in(store.files(), prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
|
||||
Some(Recovery { project: s.project, autosave, dirty: s.dirty })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?;
|
||||
// After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`.
|
||||
let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists());
|
||||
let next = crate::autosave::increment_path(&cur, |p| s.file_ops().is_file(std::path::Path::new(p)));
|
||||
save_to(s, &next)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
|
||||
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let root = s.default_autosave_root();
|
||||
let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
|
||||
let latest = crate::autosave::latest_in(s.file_ops(), &s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
|
||||
Ok(json!({
|
||||
"lastAutoSave": s.autosave.last_path,
|
||||
"latestOnDisk": latest,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
//! Persistent configuration storage (Settings, keyboard shortcut presets, the crash-recovery
|
||||
//! sentinel). The engine only sees the [`ConfigStore`] trait; frontends choose where it lives:
|
||||
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app can back it
|
||||
//! with `localStorage`, tests and headless runs use [`MemoryConfig`] (or none at all).
|
||||
//! the desktop app uses a [`DirConfig`] in the platform config directory, the web app backs it
|
||||
//! with the browser's Origin Private File System (IndexedDB fallback), tests and headless runs use
|
||||
//! [`MemoryConfig`] (or none at all).
|
||||
//!
|
||||
//! Auto-saves are project files, not settings, but they live wherever the store says: the store's
|
||||
//! [`FileOps`] (the file system by default; the browser's storage on the web).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -12,11 +16,52 @@ pub trait ConfigStore: Send + Sync {
|
|||
fn read(&self, name: &str) -> Option<String>;
|
||||
fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
|
||||
fn remove(&self, name: &str) -> std::io::Result<()>;
|
||||
/// A directory on disk next to the configuration (default Auto-Save folder for untitled
|
||||
/// projects); `None` for stores that aren't on the file system.
|
||||
/// The folder next to the configuration (default Auto-Save folder for untitled projects),
|
||||
/// in the namespace of [`ConfigStore::files`]; `None` when there is none.
|
||||
fn dir(&self) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
/// Where auto-saves are listed, written and found (default: the file system).
|
||||
fn files(&self) -> &dyn FileOps {
|
||||
&StdFiles
|
||||
}
|
||||
}
|
||||
|
||||
/// The file operations auto-save and crash recovery need.
|
||||
pub trait FileOps: Send + Sync {
|
||||
/// Files directly in `dir`: (file name, modified time).
|
||||
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)>;
|
||||
/// Write `data` to `path` atomically, creating its folder.
|
||||
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()>;
|
||||
fn remove(&self, path: &Path) -> std::io::Result<()>;
|
||||
fn is_file(&self, path: &Path) -> bool;
|
||||
}
|
||||
|
||||
/// [`FileOps`] on the file system.
|
||||
pub struct StdFiles;
|
||||
|
||||
impl FileOps for StdFiles {
|
||||
fn list(&self, dir: &Path) -> Vec<(String, std::time::SystemTime)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
rd.flatten()
|
||||
.filter_map(|e| {
|
||||
let m = e.metadata().ok().filter(|m| m.is_file())?.modified().ok()?;
|
||||
Some((e.file_name().to_string_lossy().to_string(), m))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn write(&self, path: &Path, data: &[u8]) -> std::io::Result<()> {
|
||||
if let Some(d) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(d)?;
|
||||
}
|
||||
atomic_write(path, data)
|
||||
}
|
||||
fn remove(&self, path: &Path) -> std::io::Result<()> {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
fn is_file(&self, path: &Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory store (tests, headless sessions).
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub mod demo;
|
|||
pub mod links;
|
||||
pub mod mask_track;
|
||||
pub mod menus;
|
||||
pub mod offload;
|
||||
pub mod prefs;
|
||||
pub mod render_queue;
|
||||
pub mod roto;
|
||||
|
|
@ -297,6 +298,11 @@ pub struct Session {
|
|||
/// The JavaScript scripting engine (set by the host that links `effectcraft-script`):
|
||||
/// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console.
|
||||
pub script: Option<ScriptRunner>,
|
||||
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
|
||||
/// Web Workers, see [`offload`]); `None` = threads (desktop) or inline (wasm32).
|
||||
pub offload: Option<Arc<dyn offload::Offload>>,
|
||||
/// Analyses running in the [`Session::offload`] worker.
|
||||
pub offloaded: Vec<offload::OffloadedJob>,
|
||||
}
|
||||
|
||||
/// A script to run (see [`Session::script`]).
|
||||
|
|
@ -348,6 +354,8 @@ impl Default for Session {
|
|||
autosave: autosave::AutoSaveState::default(),
|
||||
snapshot: None,
|
||||
script: None,
|
||||
offload: None,
|
||||
offloaded: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ fn run_work(w: MaskWork, shared: &MaskTrackShared) {
|
|||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
|
||||
s.time = w.times[k].seconds();
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
let mut s = lock(&shared.state);
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
|
|
@ -213,16 +214,19 @@ fn write_frames(p: &mut Project, job: &MaskTrackJob, frames: &[MaskFrame]) {
|
|||
impl Session {
|
||||
/// Whether a mask track is running.
|
||||
pub fn is_mask_tracking(&self) -> bool {
|
||||
self.mask_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
self.mask_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::MaskTrack).is_some()
|
||||
}
|
||||
|
||||
/// Live progress of the running (or just finished) mask track.
|
||||
pub fn mask_track_progress(&self) -> Option<TrackProgress> {
|
||||
self.mask_job.as_ref().map(|j| j.progress())
|
||||
self.mask_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::MaskTrack).map(|j| j.progress.track()))
|
||||
}
|
||||
|
||||
/// Cancel the running mask track (frames tracked so far are kept).
|
||||
pub fn stop_mask_track(&mut self) -> bool {
|
||||
if self.cancel_offloaded(crate::offload::JobKind::MaskTrack) {
|
||||
return true;
|
||||
}
|
||||
match &self.mask_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.shared.cancel.store(true, Ordering::Relaxed);
|
||||
|
|
@ -237,6 +241,10 @@ impl Session {
|
|||
return Err("a track analysis is already running".into());
|
||||
}
|
||||
self.poll_mask_track();
|
||||
if !wait && self.offloads() {
|
||||
let MaskWork { comp, layer, path, method, times, .. } = work;
|
||||
return self.offload_analysis(crate::offload::WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times });
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
self.history.undo.push(("Track Mask".into(), self.project.clone()));
|
||||
self.history.redo.clear();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,741 @@
|
|||
//! Background jobs where the platform has no threads (the browser).
|
||||
//!
|
||||
//! On the desktop, renders and analyses run on threads that share the session's project and
|
||||
//! footage. A wasm build without atomics has one thread per instance, so the web app runs a
|
||||
//! second engine instance in a Web Worker instead and talks to it with messages: an
|
||||
//! [`Offload`] (set as [`Session::offload`]) ships a [`WorkerRequest`] — the serialized project,
|
||||
//! the footage files it reads and the job — and feeds the worker's [`WorkerReply`]s back into an
|
||||
//! [`Inbox`] that the session drains every frame ([`Session::poll_render`],
|
||||
//! [`Session::poll_offload`]). The worker side is [`run_request`]: a plain session fed the
|
||||
//! project runs the job blocking and reports through a callback.
|
||||
//!
|
||||
//! | Job | Worker runs | Reply applied on the UI thread |
|
||||
//! |---|---|---|
|
||||
//! | Render Queue | the export of every queued item | item status, progress; files arrive through the host |
|
||||
//! | Warp Stabilizer analysis | [`Session::start_warp`] | the effect's property group (one undo step) |
|
||||
//! | 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 fills the session's segmentation cache rather than the project, so it
|
||||
//! keeps running on the UI thread.
|
||||
//!
|
||||
//! Cancelling terminates the worker: what an analysis tracked so far is dropped and the Render
|
||||
//! Queue item being rendered becomes "User Stopped".
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_keyframe::ShapePath;
|
||||
use effectcraft_project::render_queue::{RenderQueueItem, RenderStatus};
|
||||
use effectcraft_project::tracking::TrackerSettings;
|
||||
use effectcraft_project::{ItemId, ItemKind, LayerId, Project, PropGroup, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::mask_track::MaskMethod;
|
||||
use crate::render_queue::{ItemUpdate, JobShared, JobState};
|
||||
use crate::tracking::PointValues;
|
||||
use crate::{Event, Session};
|
||||
|
||||
/// One job for a worker.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkerRequest {
|
||||
pub id: u64,
|
||||
/// The project ([`Project::to_json`]).
|
||||
pub project: String,
|
||||
/// Footage files the job reads; the host ships their bytes before the request.
|
||||
pub files: Vec<String>,
|
||||
pub job: WorkerJob,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum WorkerJob {
|
||||
/// Render Queue: per item, one (item with that output module, resolved output path) per
|
||||
/// output module.
|
||||
Render {
|
||||
items: Vec<Vec<(RenderQueueItem, String)>>,
|
||||
},
|
||||
Warp {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
effect: Uid,
|
||||
},
|
||||
RotoFreeze {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
effect: Uid,
|
||||
},
|
||||
Track {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
tracker: Uid,
|
||||
direction: crate::tracking::Direction,
|
||||
settings: TrackerSettings,
|
||||
points: Vec<PointValues>,
|
||||
times: Vec<Tick>,
|
||||
},
|
||||
MaskTrack {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
mask: Uid,
|
||||
direction: crate::tracking::Direction,
|
||||
path: ShapePath,
|
||||
method: MaskMethod,
|
||||
times: Vec<Tick>,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorkerJob {
|
||||
pub fn kind(&self) -> JobKind {
|
||||
match self {
|
||||
WorkerJob::Render { .. } => JobKind::Render,
|
||||
WorkerJob::Warp { .. } => JobKind::Warp,
|
||||
WorkerJob::RotoFreeze { .. } => JobKind::RotoFreeze,
|
||||
WorkerJob::Track { .. } => JobKind::Track,
|
||||
WorkerJob::MaskTrack { .. } => JobKind::MaskTrack,
|
||||
}
|
||||
}
|
||||
|
||||
/// (comp, layer, property group uid) an analysis writes to.
|
||||
pub fn target(&self) -> Option<(ItemId, LayerId, Uid)> {
|
||||
match *self {
|
||||
WorkerJob::Render { .. } => None,
|
||||
WorkerJob::Warp { comp, layer, effect } | WorkerJob::RotoFreeze { 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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum JobKind {
|
||||
Render,
|
||||
Warp,
|
||||
RotoFreeze,
|
||||
Track,
|
||||
MaskTrack,
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
/// The undo step an analysis result becomes.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
JobKind::Render => "Render",
|
||||
JobKind::Warp => "Warp Stabilizer Analysis",
|
||||
JobKind::RotoFreeze => "Freeze",
|
||||
JobKind::Track => "Analyze Track",
|
||||
JobKind::MaskTrack => "Track Mask",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a worker reports.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum WorkerReply {
|
||||
/// Render Queue progress.
|
||||
Render {
|
||||
state: JobState,
|
||||
},
|
||||
/// A Render Queue item started or finished.
|
||||
Item {
|
||||
update: ItemUpdate,
|
||||
},
|
||||
/// Analysis progress (frames).
|
||||
Progress {
|
||||
done: u64,
|
||||
total: u64,
|
||||
},
|
||||
/// An analysis finished: its property group after the job, and the job's message.
|
||||
Group {
|
||||
comp: ItemId,
|
||||
layer: LayerId,
|
||||
group: Box<PropGroup>,
|
||||
message: String,
|
||||
},
|
||||
Failed {
|
||||
error: String,
|
||||
},
|
||||
/// The job ended (always the last reply).
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Replies waiting for the UI thread.
|
||||
#[derive(Default)]
|
||||
pub struct Inbox(Mutex<Vec<WorkerReply>>);
|
||||
|
||||
impl Inbox {
|
||||
pub fn push(&self, r: WorkerReply) {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner()).push(r);
|
||||
}
|
||||
pub fn take(&self) -> Vec<WorkerReply> {
|
||||
std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs jobs off the UI thread (the web app: a pool of Web Workers).
|
||||
pub trait Offload: Send + Sync {
|
||||
/// Start `req`; replies go to `inbox` (ending with [`WorkerReply::Done`]).
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String>;
|
||||
/// Stop job `id` now (the worker is terminated; no more replies arrive).
|
||||
fn cancel(&self, id: u64);
|
||||
}
|
||||
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
pub fn next_id() -> u64 {
|
||||
NEXT_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Footage files a project reads (paths, sequence frames).
|
||||
pub fn footage_files(p: &Project) -> Vec<String> {
|
||||
let mut v: Vec<String> = p
|
||||
.items
|
||||
.values()
|
||||
.filter_map(|i| match &i.kind {
|
||||
ItemKind::Footage(f) if !f.path.is_empty() => Some(std::iter::once(f.path.clone()).chain(f.sequence.iter().cloned()).collect::<Vec<_>>()),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- progress hook (worker side)
|
||||
|
||||
thread_local! {
|
||||
static HOOK: RefCell<Option<Box<dyn FnMut(u64, u64)>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Analyses call this as frames complete; a worker forwards it (no-op elsewhere).
|
||||
pub fn report(done: u64, total: u64) {
|
||||
HOOK.with(|h| {
|
||||
if let Ok(mut h) = h.try_borrow_mut()
|
||||
&& let Some(f) = h.as_mut()
|
||||
{
|
||||
f(done, total);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn with_hook<R>(hook: Box<dyn FnMut(u64, u64)>, f: impl FnOnce() -> R) -> R {
|
||||
HOOK.with(|h| *h.borrow_mut() = Some(hook));
|
||||
let r = f();
|
||||
HOOK.with(|h| *h.borrow_mut() = None);
|
||||
r
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- worker side
|
||||
|
||||
/// Where a worker sends its replies.
|
||||
pub type Post = Rc<dyn Fn(WorkerReply)>;
|
||||
|
||||
/// Run `req` on `s` (a session with footage, expressions and an exporter, but no project yet),
|
||||
/// blocking, reporting through `post`. Always ends with [`WorkerReply::Done`].
|
||||
pub fn run_request(s: &mut Session, req: WorkerRequest, post: &Post) {
|
||||
match Project::from_json(&req.project) {
|
||||
Ok(p) => {
|
||||
s.replace_project(p, None);
|
||||
if let Err(e) = run_job(s, req.job, post.clone()) {
|
||||
post(WorkerReply::Failed { error: e });
|
||||
}
|
||||
}
|
||||
Err(e) => post(WorkerReply::Failed { error: format!("project: {e}") }),
|
||||
}
|
||||
post(WorkerReply::Done);
|
||||
}
|
||||
|
||||
fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> {
|
||||
let kind = job.kind();
|
||||
let target = job.target();
|
||||
match job {
|
||||
WorkerJob::Render { items } => {
|
||||
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
|
||||
let shared = JobShared::default();
|
||||
// Item updates and progress go out as they happen (progress at most every 100 ms).
|
||||
let last = Cell::new(None::<web_time::Instant>);
|
||||
let notify = |sh: &JobShared, force: bool| {
|
||||
let ups = std::mem::take(&mut *crate::render_queue::lock(&sh.updates));
|
||||
for u in ups {
|
||||
post(WorkerReply::Item { update: u });
|
||||
}
|
||||
if force || last.get().is_none_or(|t| t.elapsed().as_millis() >= 100) {
|
||||
last.set(Some(web_time::Instant::now()));
|
||||
post(WorkerReply::Render { state: sh.snapshot() });
|
||||
}
|
||||
};
|
||||
crate::render_queue::run_items(s, exporter, items, &shared, &mut |sh| notify(sh, false));
|
||||
notify(&shared, true);
|
||||
Ok(())
|
||||
}
|
||||
WorkerJob::Warp { comp, layer, effect } => analysis(s, kind, target, post, |s| s.start_warp(comp, layer, effect, true).map(|_| ())),
|
||||
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::Track { comp, layer, tracker, direction, settings, points, times } => analysis(s, kind, target, post, |s| {
|
||||
let work = crate::tracking::Work {
|
||||
project: s.project.clone(),
|
||||
footage: s.footage.clone(),
|
||||
expr: s.expr.clone(),
|
||||
cache: s.layer_cache.clone(),
|
||||
comp,
|
||||
layer,
|
||||
settings,
|
||||
points,
|
||||
times,
|
||||
};
|
||||
s.start_track(work, tracker, direction, true)
|
||||
}),
|
||||
WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times } => analysis(s, kind, target, post, |s| {
|
||||
let work = crate::mask_track::MaskWork {
|
||||
project: s.project.clone(),
|
||||
footage: s.footage.clone(),
|
||||
expr: s.expr.clone(),
|
||||
cache: s.layer_cache.clone(),
|
||||
comp,
|
||||
layer,
|
||||
path,
|
||||
method,
|
||||
times,
|
||||
};
|
||||
s.start_mask_track(work, mask, direction, true)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn analysis(
|
||||
s: &mut Session,
|
||||
kind: JobKind,
|
||||
target: Option<(ItemId, LayerId, Uid)>,
|
||||
post: Post,
|
||||
run: impl FnOnce(&mut Session) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let (comp, layer, uid) = target.ok_or("no target")?;
|
||||
let rev = s.revision;
|
||||
// Progress at most every 100 ms.
|
||||
let p = post.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() >= 100) {
|
||||
last = Some(web_time::Instant::now());
|
||||
p(WorkerReply::Progress { done, total });
|
||||
}
|
||||
});
|
||||
with_hook(hook, || run(s))?;
|
||||
let message = s
|
||||
.events
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|e| match e {
|
||||
Event::Toast { message, .. } => Some(message.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let failed = s.events.iter().any(|e| matches!(e, Event::Toast { error: true, .. }));
|
||||
if failed {
|
||||
return Err(message);
|
||||
}
|
||||
if s.revision == rev && kind != JobKind::Track && kind != JobKind::MaskTrack {
|
||||
// Nothing written (e.g. the analysis was empty).
|
||||
return Err(if message.is_empty() { "nothing was analysed".into() } else { message });
|
||||
}
|
||||
let group = s.project.comp(comp).and_then(|c| c.layer(layer)).and_then(|l| l.props.find_group(uid)).cloned().ok_or("the analysed property is gone")?;
|
||||
post(WorkerReply::Group { comp, layer, group: Box::new(group), message });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- UI side
|
||||
|
||||
/// Analysis progress of an offloaded job.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OffloadProgress {
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
pub elapsed: f64,
|
||||
pub finished: bool,
|
||||
pub cancelled: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Frames per second of `done` frames in `elapsed` seconds.
|
||||
pub(crate) fn rate(done: u64, elapsed: f64) -> f64 {
|
||||
if elapsed > 0.0 { done as f64 / elapsed } else { 0.0 }
|
||||
}
|
||||
|
||||
impl OffloadProgress {
|
||||
pub(crate) fn track(&self) -> crate::tracking::TrackProgress {
|
||||
crate::tracking::TrackProgress { done: self.done, total: self.total, elapsed: self.elapsed, fps: rate(self.done, self.elapsed), ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// An analysis running in a worker.
|
||||
pub struct OffloadedJob {
|
||||
pub id: u64,
|
||||
pub kind: JobKind,
|
||||
pub comp: ItemId,
|
||||
pub layer: LayerId,
|
||||
pub uid: Uid,
|
||||
pub inbox: Arc<Inbox>,
|
||||
pub progress: OffloadProgress,
|
||||
started: web_time::Instant,
|
||||
}
|
||||
|
||||
/// A Render Queue job running in a worker (see [`crate::render_queue::RenderJob`]).
|
||||
pub struct RemoteRender {
|
||||
pub id: u64,
|
||||
pub inbox: Arc<Inbox>,
|
||||
/// The queue items in the job.
|
||||
pub ids: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Whether jobs started without `wait` go to the [`Offload`].
|
||||
pub fn offloads(&self) -> bool {
|
||||
self.offload.is_some()
|
||||
}
|
||||
|
||||
/// The offloaded analysis of `kind`, if one runs.
|
||||
pub fn offloaded(&self, kind: JobKind) -> Option<&OffloadedJob> {
|
||||
self.offloaded.iter().find(|j| j.kind == kind && !j.progress.finished)
|
||||
}
|
||||
|
||||
/// Send an analysis to the worker.
|
||||
pub(crate) fn offload_analysis(&mut self, job: WorkerJob) -> Result<(), String> {
|
||||
let off = self.offload.clone().ok_or("no background worker")?;
|
||||
let kind = job.kind();
|
||||
let (comp, layer, uid) = job.target().ok_or("not an analysis")?;
|
||||
if self.offloaded(kind).is_some() {
|
||||
return Err(format!("a {} job is already running", kind.label()));
|
||||
}
|
||||
let id = next_id();
|
||||
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.events.push(Event::ProjectChanged { revision: self.revision });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel the offloaded analysis of `kind` (nothing is written).
|
||||
pub fn cancel_offloaded(&mut self, kind: JobKind) -> bool {
|
||||
let off = self.offload.clone();
|
||||
let Some(j) = self.offloaded.iter_mut().find(|j| j.kind == kind && !j.progress.finished) else { return false };
|
||||
if let Some(off) = off {
|
||||
off.cancel(j.id);
|
||||
}
|
||||
j.progress.cancelled = true;
|
||||
j.progress.finished = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Apply worker replies to offloaded analyses (results become one undo step each) and drop
|
||||
/// finished jobs. Frontends call this every frame while [`Session::offloaded`] is not empty.
|
||||
pub fn poll_offload(&mut self) -> bool {
|
||||
if self.offloaded.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
let mut results = vec![];
|
||||
for j in &mut self.offloaded {
|
||||
if j.progress.cancelled {
|
||||
continue;
|
||||
}
|
||||
for r in j.inbox.take() {
|
||||
changed = true;
|
||||
match r {
|
||||
WorkerReply::Progress { done, total } => {
|
||||
j.progress.done = done;
|
||||
j.progress.total = total;
|
||||
}
|
||||
WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)),
|
||||
WorkerReply::Failed { error } => j.progress.error = Some(error),
|
||||
WorkerReply::Done => j.progress.finished = true,
|
||||
WorkerReply::Render { .. } | WorkerReply::Item { .. } => {}
|
||||
}
|
||||
}
|
||||
j.progress.elapsed = j.started.elapsed().as_secs_f64();
|
||||
}
|
||||
for (kind, comp, layer, group, message) in results {
|
||||
let uid = group.uid;
|
||||
let r = self.edit(kind.label(), None, |p, _| {
|
||||
let g = p
|
||||
.comp_mut(comp)
|
||||
.and_then(|c| c.layer_mut(layer))
|
||||
.and_then(|l| l.props.find_group_mut(uid))
|
||||
.ok_or_else(|| crate::EngineError::Other("the analysed property was removed during the analysis".into()))?;
|
||||
*g = *group;
|
||||
Ok(())
|
||||
});
|
||||
match r {
|
||||
Ok(()) => self.events.push(Event::Toast { message, error: false }),
|
||||
Err(e) => self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true }),
|
||||
}
|
||||
}
|
||||
let mut done = vec![];
|
||||
self.offloaded.retain(|j| {
|
||||
if j.progress.finished {
|
||||
done.push((j.kind, j.progress.clone()));
|
||||
}
|
||||
!j.progress.finished
|
||||
});
|
||||
for (kind, p) in done {
|
||||
changed = true;
|
||||
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 });
|
||||
}
|
||||
self.bump();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Feed a remote render's replies into its [`JobShared`]; a cancelled one is wrapped up here
|
||||
/// (the item being rendered and the ones after it become "User Stopped").
|
||||
pub(crate) fn drain_remote_render(&mut self) {
|
||||
let Some(job) = &self.render_job else { return };
|
||||
let Some(remote) = &job.remote else { return };
|
||||
let shared = job.shared.clone();
|
||||
for r in remote.inbox.take() {
|
||||
match r {
|
||||
WorkerReply::Render { state } => {
|
||||
let fin = crate::render_queue::lock(&shared.state).finished;
|
||||
*crate::render_queue::lock(&shared.state) = JobState { finished: fin, ..state };
|
||||
}
|
||||
WorkerReply::Item { update } => crate::render_queue::lock(&shared.updates).push(update),
|
||||
WorkerReply::Failed { error } => {
|
||||
log::warn!("render worker: {error}");
|
||||
let cur = crate::render_queue::lock(&shared.state).current;
|
||||
if let Some(id) = cur {
|
||||
crate::render_queue::lock(&shared.updates).push(ItemUpdate::Finished {
|
||||
id,
|
||||
status: RenderStatus::Failed(error),
|
||||
seconds: 0.0,
|
||||
output: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
WorkerReply::Done => crate::render_queue::lock(&shared.state).finished = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn round<T: Serialize + for<'de> Deserialize<'de>>(v: &T) -> String {
|
||||
let a = serde_json::to_string(v).unwrap();
|
||||
let back: T = serde_json::from_str(&a).unwrap();
|
||||
let b = serde_json::to_string(&back).unwrap();
|
||||
assert_eq!(a, b);
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_round_trips() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
|
||||
let comp = s.active_comp_id().unwrap();
|
||||
s.execute("renderQueue.add", serde_json::json!({})).unwrap();
|
||||
let item = s.project.render_queue[0].clone();
|
||||
let reqs = [
|
||||
WorkerJob::Render { items: vec![vec![(item.clone(), "/out.gif".into())]] },
|
||||
WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 },
|
||||
WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 },
|
||||
WorkerJob::Track {
|
||||
comp,
|
||||
layer: LayerId(1),
|
||||
tracker: 4,
|
||||
direction: crate::tracking::Direction::Backward,
|
||||
settings: TrackerSettings::default(),
|
||||
points: vec![PointValues {
|
||||
uid: 5,
|
||||
spec: effectcraft_track::PointSpec { center: [1.0, 2.0], feature_size: [3.0, 4.0], search_offset: [0.5, 0.0], search_size: [9.0, 9.0] },
|
||||
attach_offset: [0.25, -1.0],
|
||||
}],
|
||||
times: vec![Tick::ZERO, Tick::from_seconds_f64(0.5)],
|
||||
},
|
||||
WorkerJob::MaskTrack {
|
||||
comp,
|
||||
layer: LayerId(2),
|
||||
mask: 7,
|
||||
direction: crate::tracking::Direction::Forward,
|
||||
path: ShapePath::ellipse([10.0, 20.0], 5.0, 6.0),
|
||||
method: MaskMethod::Perspective,
|
||||
times: vec![Tick::from_seconds_f64(1.0)],
|
||||
},
|
||||
];
|
||||
for job in reqs {
|
||||
let kind = job.kind();
|
||||
let req = WorkerRequest { id: 42, project: s.project.to_json(), files: footage_files(&s.project), job };
|
||||
let t = round(&req);
|
||||
assert!(t.contains("\"kind\":"), "{kind:?}");
|
||||
let back: WorkerRequest = serde_json::from_str(&t).unwrap();
|
||||
assert_eq!(back.job.kind(), kind);
|
||||
assert_eq!(Project::from_json(&back.project).unwrap(), *s.project);
|
||||
}
|
||||
let g = s.active_comp().unwrap().layers[0].props.clone();
|
||||
for r in [
|
||||
WorkerReply::Render { state: JobState { current: Some(3), done: 4, total: 10, items_total: 1, ..Default::default() } },
|
||||
WorkerReply::Item { update: ItemUpdate::Started { id: 3, unix: 1_700_000_000 } },
|
||||
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::Failed { error: "boom".into() },
|
||||
WorkerReply::Done,
|
||||
] {
|
||||
let t = round(&r);
|
||||
assert!(t.starts_with("{\"type\":"), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An [`Offload`] that runs the job right away in a second session, through JSON (what a
|
||||
/// Web Worker does, minus the threads).
|
||||
struct Inline;
|
||||
impl Offload for Inline {
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
|
||||
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
||||
let mut w = Session { exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })), ..Default::default() };
|
||||
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
|
||||
run_request(&mut w, req, &post);
|
||||
Ok(())
|
||||
}
|
||||
fn cancel(&self, _: u64) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_runs_through_the_offload() {
|
||||
let mut s = Session {
|
||||
exporter: Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() })),
|
||||
offload: Some(Arc::new(Inline)),
|
||||
..Default::default()
|
||||
};
|
||||
s.execute("file.openDemoProject", serde_json::json!({})).unwrap();
|
||||
s.execute("renderQueue.add", serde_json::json!({"output": "/x.gif"})).unwrap();
|
||||
let ids = s.start_render(false).unwrap();
|
||||
// (the inline offload has replied already; replies are applied when polled)
|
||||
s.poll_render();
|
||||
assert!(s.render_job.is_none());
|
||||
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
|
||||
assert_eq!(it.status, RenderStatus::Done, "{:?}", it.status);
|
||||
assert!(it.last_output.as_deref().is_some_and(|o| o.ends_with("x.gif")));
|
||||
// `wait` renders inline.
|
||||
s.execute("renderQueue.add", serde_json::json!({"output": "/y.gif"})).unwrap();
|
||||
s.start_render(true).unwrap();
|
||||
assert!(s.render_job.is_none() || s.render_job.as_ref().unwrap().remote.is_none());
|
||||
}
|
||||
|
||||
/// Runs jobs in a session sharing `footage` (the synthetic clip of the tracking tests).
|
||||
struct InlineWith(Arc<dyn effectcraft_render::FootageSource>);
|
||||
impl Offload for InlineWith {
|
||||
fn start(&self, req: WorkerRequest, inbox: Arc<Inbox>) -> Result<(), String> {
|
||||
let req: WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
|
||||
let mut w = Session { footage: self.0.clone(), ..Default::default() };
|
||||
let post: Post = Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
|
||||
run_request(&mut w, req, &post);
|
||||
Ok(())
|
||||
}
|
||||
fn cancel(&self, _: u64) {}
|
||||
}
|
||||
|
||||
/// Never replies (a job still running); records cancellations.
|
||||
#[derive(Default)]
|
||||
struct Hanging(Mutex<Vec<u64>>);
|
||||
impl Offload for Hanging {
|
||||
fn start(&self, _: WorkerRequest, _: Arc<Inbox>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn cancel(&self, id: u64) {
|
||||
self.0.lock().unwrap().push(id);
|
||||
}
|
||||
}
|
||||
|
||||
fn pose(f: u32) -> ([f64; 2], f64, f64) {
|
||||
([150.0 + 2.0 * f as f64, 110.0 + 1.0 * f as f64], 0.0, 1.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_analysis_runs_through_the_offload_as_one_undo_step() {
|
||||
let (mut s, clip, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose));
|
||||
s.offload = Some(Arc::new(InlineWith(s.footage.clone())));
|
||||
s.execute("track.motion", serde_json::json!({})).unwrap();
|
||||
s.execute("track.setPoint", serde_json::json!({"point": 1, "center": pose(0).0, "featureSize": [36, 36], "searchSize": [80, 80]})).unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
s.execute("track.analyze", serde_json::json!({"direction": "forward"})).unwrap();
|
||||
assert!(s.is_tracking() && s.track_job.is_none(), "offloaded, not on a thread");
|
||||
assert!(s.track_progress().is_some());
|
||||
assert!(s.poll_offload());
|
||||
assert!(!s.is_tracking() && s.offloaded.is_empty());
|
||||
let l = s.active_comp().unwrap().layer(clip).unwrap();
|
||||
let tp = l.trackers().next().unwrap().0.track_points().next().unwrap().clone();
|
||||
let fc = tp.get("featureCenter").unwrap();
|
||||
assert_eq!(fc.keys.len(), 25);
|
||||
let v = fc.value_at(crate::tests_track::frame_time(12)).as_vec2();
|
||||
assert!((v[0] - pose(12).0[0]).abs() < 0.3 && (v[1] - pose(12).0[1]).abs() < 0.3, "{v:?}");
|
||||
assert_eq!(s.history.undo.len(), undo + 1);
|
||||
assert_eq!(s.history.undo.last().unwrap().0, "Analyze Track");
|
||||
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, error: false } if message.starts_with("Tracked"))));
|
||||
assert!(s.undo());
|
||||
let l = s.active_comp().unwrap().layer(clip).unwrap();
|
||||
assert!(l.trackers().next().unwrap().0.track_points().next().unwrap().get("featureCenter").unwrap().keys.is_empty());
|
||||
// `wait` runs inline.
|
||||
s.redo();
|
||||
s.execute("track.analyze", serde_json::json!({"direction": "forward", "wait": true})).ok();
|
||||
assert!(s.offloaded.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_offloaded_jobs() {
|
||||
let (mut s, _, _) = crate::tests_track::setup(crate::tests_track::patch_frames(pose));
|
||||
let off = Arc::new(Hanging::default());
|
||||
s.offload = Some(off.clone());
|
||||
s.exporter = Some(Arc::new(crate::rq_tests::MockExporter { log: Default::default() }));
|
||||
// Analysis: stopping drops the job, nothing is written.
|
||||
s.execute("track.motion", serde_json::json!({})).unwrap();
|
||||
let rev = s.revision;
|
||||
s.execute("track.analyze", serde_json::json!({})).unwrap();
|
||||
assert!(s.is_tracking());
|
||||
assert!(s.execute("track.analyze", serde_json::json!({})).is_err(), "one at a time");
|
||||
assert!(s.stop_track());
|
||||
s.poll_offload();
|
||||
assert!(!s.is_tracking());
|
||||
assert_eq!(off.0.lock().unwrap().len(), 1);
|
||||
assert!(s.events.iter().any(|e| matches!(e, Event::Toast { message, .. } if message.contains("stopped"))));
|
||||
assert!(s.revision > rev);
|
||||
// Render: the queued items become User Stopped.
|
||||
s.execute("renderQueue.add", serde_json::json!({"output": "/a.gif"})).unwrap();
|
||||
let ids = s.start_render(false).unwrap();
|
||||
assert!(s.is_rendering());
|
||||
assert!(s.stop_render());
|
||||
s.poll_render();
|
||||
assert!(!s.is_rendering());
|
||||
assert_eq!(off.0.lock().unwrap().len(), 2);
|
||||
let it = s.project.render_queue.iter().find(|i| i.id == ids[0]).unwrap();
|
||||
assert_eq!(it.status, RenderStatus::UserStopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_hook_is_scoped() {
|
||||
report(1, 2); // no hook: nothing happens
|
||||
let seen = Rc::new(RefCell::new(vec![]));
|
||||
let s2 = seen.clone();
|
||||
with_hook(Box::new(move |d, t| s2.borrow_mut().push((d, t))), || {
|
||||
report(1, 3);
|
||||
report(2, 3);
|
||||
});
|
||||
report(3, 3);
|
||||
assert_eq!(*seen.borrow(), vec![(1, 3), (2, 3)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex};
|
|||
use effectcraft_project::render_queue::{OutputFormat, PostRenderAction, RenderQueueItem, RenderStatus, TemplateVars, expand_template};
|
||||
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project};
|
||||
use effectcraft_render::{ExprHost, FootageSource};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Event, Session};
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ pub trait Exporter: Send + Sync {
|
|||
pub const CANCELLED: &str = "cancelled";
|
||||
|
||||
/// Live progress of a render job (serde for agents / the control channel).
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct JobState {
|
||||
/// Item being rendered.
|
||||
pub current: Option<u64>,
|
||||
|
|
@ -73,7 +73,8 @@ pub struct JobState {
|
|||
}
|
||||
|
||||
/// Per-item status changes from the job thread, applied to the project by `poll_render`.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "event", rename_all = "camelCase")]
|
||||
pub enum ItemUpdate {
|
||||
Started { id: u64, unix: u64 },
|
||||
Finished { id: u64, status: RenderStatus, seconds: f64, output: Option<String> },
|
||||
|
|
@ -86,7 +87,7 @@ pub struct JobShared {
|
|||
pub updates: Mutex<Vec<ItemUpdate>>,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +101,8 @@ impl JobShared {
|
|||
pub struct RenderJob {
|
||||
pub shared: Arc<JobShared>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
/// Rendering in a worker ([`crate::offload`]).
|
||||
pub remote: Option<crate::offload::RemoteRender>,
|
||||
}
|
||||
|
||||
impl RenderJob {
|
||||
|
|
@ -132,7 +135,19 @@ fn unix_now() -> u64 {
|
|||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn run_work(w: Work, shared: &JobShared) {
|
||||
/// Render `items` with the session's footage, expressions and GPU (a worker's render).
|
||||
pub(crate) fn run_items(
|
||||
s: &Session,
|
||||
exporter: Arc<dyn Exporter>,
|
||||
items: Vec<Vec<(RenderQueueItem, String)>>,
|
||||
shared: &JobShared,
|
||||
notify: &mut dyn FnMut(&JobShared),
|
||||
) {
|
||||
let work = Work { project: s.project.clone(), footage: s.footage.clone(), expr: s.expr.clone(), accel: s.accel.clone(), exporter, items };
|
||||
run_work(work, shared, notify);
|
||||
}
|
||||
|
||||
fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared)) {
|
||||
let t0 = web_time::Instant::now();
|
||||
{
|
||||
let mut s = lock(&shared.state);
|
||||
|
|
@ -154,18 +169,22 @@ fn run_work(w: Work, shared: &JobShared) {
|
|||
s.items_done = k;
|
||||
s.remaining = None;
|
||||
}
|
||||
notify(shared);
|
||||
// Every output module encodes the same frames (Composition ▸ Add Output Module); the
|
||||
// first module's file is the item's output.
|
||||
let mut r: Result<ExportResult, String> = Err("no output module".into());
|
||||
for (mi, (mitem, path)) in modules.iter().enumerate() {
|
||||
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path };
|
||||
let rr = w.exporter.export(&job, &mut |done, total| {
|
||||
{
|
||||
let mut s = lock(&shared.state);
|
||||
s.done = done;
|
||||
s.total = total;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.item_elapsed = t1.elapsed().as_secs_f64();
|
||||
s.remaining = (done > 0).then(|| s.item_elapsed / done as f64 * total.saturating_sub(done) as f64);
|
||||
}
|
||||
notify(shared);
|
||||
!shared.cancel.load(Ordering::Relaxed)
|
||||
});
|
||||
match rr {
|
||||
|
|
@ -187,6 +206,7 @@ fn run_work(w: Work, shared: &JobShared) {
|
|||
}
|
||||
};
|
||||
lock(&shared.updates).push(up);
|
||||
notify(shared);
|
||||
}
|
||||
let mut s = lock(&shared.state);
|
||||
s.items_done = w.items.len();
|
||||
|
|
@ -245,10 +265,12 @@ impl Session {
|
|||
}
|
||||
|
||||
/// Start rendering every queued item. `wait`: block until done (otherwise a background thread
|
||||
/// renders; call [`Session::poll_render`] regularly). Returns the ids being rendered.
|
||||
/// wasm32 has no threads: the render always runs to completion before this returns.
|
||||
/// renders, or the [`Session::offload`] worker; call [`Session::poll_render`] regularly).
|
||||
/// Returns the ids being rendered. Without threads or an offload (wasm32) the render always
|
||||
/// runs to completion before this returns.
|
||||
pub fn start_render(&mut self, wait: bool) -> Result<Vec<u64>, String> {
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
let offload = self.offload.clone().filter(|_| !wait);
|
||||
let wait = wait || (cfg!(target_arch = "wasm32") && offload.is_none());
|
||||
if self.is_rendering() {
|
||||
return Err("a render is already in progress".into());
|
||||
}
|
||||
|
|
@ -291,15 +313,31 @@ impl Session {
|
|||
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
|
||||
}
|
||||
let ids: Vec<u64> = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect();
|
||||
if let Some(off) = offload {
|
||||
let id = crate::offload::next_id();
|
||||
let inbox = Arc::new(crate::offload::Inbox::default());
|
||||
let req = crate::offload::WorkerRequest {
|
||||
id,
|
||||
project: self.project.to_json(),
|
||||
files: crate::offload::footage_files(&self.project),
|
||||
job: crate::offload::WorkerJob::Render { items },
|
||||
};
|
||||
off.start(req, inbox.clone())?;
|
||||
let shared = Arc::new(JobShared::default());
|
||||
lock(&shared.state).items_total = ids.len();
|
||||
self.render_job = Some(RenderJob { shared, thread: None, remote: Some(crate::offload::RemoteRender { id, inbox, ids: ids.clone() }) });
|
||||
self.poll_render();
|
||||
return Ok(ids);
|
||||
}
|
||||
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items };
|
||||
let shared = Arc::new(JobShared::default());
|
||||
if wait {
|
||||
run_work(work, &shared);
|
||||
self.render_job = Some(RenderJob { shared, thread: None });
|
||||
run_work(work, &shared, &mut |_| {});
|
||||
self.render_job = Some(RenderJob { shared, thread: None, remote: None });
|
||||
} else {
|
||||
let sh = shared.clone();
|
||||
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?;
|
||||
self.render_job = Some(RenderJob { shared, thread: Some(thread) });
|
||||
let thread = std::thread::Builder::new().name("render-queue".into()).spawn(move || run_work(work, &sh, &mut |_| {})).map_err(|e| e.to_string())?;
|
||||
self.render_job = Some(RenderJob { shared, thread: Some(thread), remote: None });
|
||||
}
|
||||
self.poll_render();
|
||||
Ok(ids)
|
||||
|
|
@ -310,6 +348,25 @@ impl Session {
|
|||
match &self.render_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.cancel();
|
||||
if let Some(r) = &j.remote {
|
||||
// A worker can't be interrupted mid-frame: terminate it and stop what's left.
|
||||
if let Some(off) = &self.offload {
|
||||
off.cancel(r.id);
|
||||
}
|
||||
self.drain_remote_render();
|
||||
let j = self.render_job.as_ref().expect("job");
|
||||
let r = j.remote.as_ref().expect("remote");
|
||||
let finished: Vec<u64> =
|
||||
lock(&j.shared.updates).iter().filter_map(|u| if let ItemUpdate::Finished { id, .. } = u { Some(*id) } else { None }).collect();
|
||||
for id in &r.ids {
|
||||
let done = finished.contains(id)
|
||||
|| self.project.render_queue.iter().any(|i| i.id == *id && matches!(i.status, RenderStatus::Done | RenderStatus::Failed(_)));
|
||||
if !done {
|
||||
lock(&j.shared.updates).push(ItemUpdate::Finished { id: *id, status: RenderStatus::UserStopped, seconds: 0.0, output: None });
|
||||
}
|
||||
}
|
||||
lock(&j.shared.state).finished = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
|
|
@ -319,6 +376,7 @@ impl Session {
|
|||
/// Apply job updates to the queue items; drop the job once it finished. Returns whether
|
||||
/// anything changed. Frontends call this every frame while rendering.
|
||||
pub fn poll_render(&mut self) -> bool {
|
||||
self.drain_remote_render();
|
||||
let Some(job) = &self.render_job else { return false };
|
||||
let updates = std::mem::take(&mut *lock(&job.shared.updates));
|
||||
let finished = job.is_finished();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
|
||||
//! mattes made from other frames.
|
||||
|
||||
use crate::offload::JobKind;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
|
@ -62,7 +63,7 @@ impl Default for RotoOptions {
|
|||
pub const VIEWS: [&str; 4] = ["alphaBoundary", "alpha", "alphaOverlay", "none"];
|
||||
|
||||
/// What a job does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RotoTask {
|
||||
Propagate,
|
||||
|
|
@ -378,6 +379,7 @@ fn run_work(w: Work, shared: &RotoShared) {
|
|||
s.done = k as u64 + 1;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
if w.task == RotoTask::Freeze {
|
||||
*lock(&shared.frozen) = Some(frozen);
|
||||
|
|
@ -402,19 +404,37 @@ pub(crate) fn job_frames(d: &effectcraft_track::roto::RotoData, dir: Direction)
|
|||
|
||||
impl Session {
|
||||
pub fn is_roto_running(&self) -> bool {
|
||||
self.roto_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::RotoFreeze).is_some()
|
||||
}
|
||||
|
||||
pub fn roto_progress(&self) -> Option<RotoProgress> {
|
||||
self.roto_job.as_ref().map(|j| j.progress())
|
||||
self.roto_job.as_ref().map(|j| j.progress()).or_else(|| {
|
||||
self.offloaded(JobKind::RotoFreeze).map(|j| {
|
||||
let p = &j.progress;
|
||||
RotoProgress {
|
||||
task: Some(RotoTask::Freeze),
|
||||
done: p.done,
|
||||
total: p.total,
|
||||
elapsed: p.elapsed,
|
||||
fps: crate::offload::rate(p.done, p.elapsed),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// (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))
|
||||
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)))
|
||||
}
|
||||
|
||||
pub fn stop_roto(&mut self) -> bool {
|
||||
if self.cancel_offloaded(JobKind::RotoFreeze) {
|
||||
return true;
|
||||
}
|
||||
match &self.roto_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.shared.cancel.store(true, Ordering::Relaxed);
|
||||
|
|
@ -471,6 +491,11 @@ impl Session {
|
|||
task,
|
||||
};
|
||||
let strokes = str_of(g, fx::STROKES);
|
||||
if !wait && task == RotoTask::Freeze && self.offloads() {
|
||||
drop(work);
|
||||
self.offload_analysis(crate::offload::WorkerJob::RotoFreeze { comp, layer, effect })?;
|
||||
return Ok(n);
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
let shared = Arc::new(RotoShared::default());
|
||||
let thread = if wait {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ fn demo() -> Session {
|
|||
}
|
||||
|
||||
/// Records exports; fails items whose path contains "fail"; honours cancellation.
|
||||
struct MockExporter {
|
||||
log: Mutex<Vec<String>>,
|
||||
pub(crate) struct MockExporter {
|
||||
pub(crate) log: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Exporter for MockExporter {
|
||||
|
|
|
|||
|
|
@ -89,11 +89,19 @@ impl Session {
|
|||
self.config.as_ref().and_then(|c| c.dir())
|
||||
}
|
||||
|
||||
/// Where auto-saves live: the config store's [`crate::config::FileOps`], else the file system.
|
||||
pub fn file_ops(&self) -> &dyn crate::config::FileOps {
|
||||
match &self.config {
|
||||
Some(c) => c.files(),
|
||||
None => &crate::config::StdFiles,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an auto-save now (whether or not the project is dirty). Returns its path.
|
||||
pub fn autosave_now(&mut self) -> Result<String> {
|
||||
let json = self.project.to_json();
|
||||
let root = self.default_autosave_root();
|
||||
let (path, slot) = autosave::write(&self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
|
||||
let (path, slot) = autosave::write_in(self.file_ops(), &self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
|
||||
.map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?;
|
||||
let p = path.to_string_lossy().to_string();
|
||||
self.autosave.last_slot = Some(slot);
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ pub fn track_options(o: &TrackerOptions) -> trk::TrackOptions {
|
|||
}
|
||||
|
||||
/// A track point's regions and attach offset read from its properties at a layer time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PointValues {
|
||||
pub uid: Uid,
|
||||
pub spec: trk::PointSpec,
|
||||
|
|
@ -292,6 +293,7 @@ pub(crate) fn run_work(w: Work, shared: &TrackShared) {
|
|||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
|
||||
s.time = w.times[k].seconds();
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
let mut s = lock(&shared.state);
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
|
|
@ -322,16 +324,19 @@ fn write_frames(p: &mut Project, job: &TrackJob, frames: &[FrameResult]) {
|
|||
impl Session {
|
||||
/// Whether a track analysis is running.
|
||||
pub fn is_tracking(&self) -> bool {
|
||||
self.track_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
self.track_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::Track).is_some()
|
||||
}
|
||||
|
||||
/// Live progress of the running (or just finished) analysis.
|
||||
pub fn track_progress(&self) -> Option<TrackProgress> {
|
||||
self.track_job.as_ref().map(|j| j.progress())
|
||||
self.track_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::Track).map(|j| j.progress.track()))
|
||||
}
|
||||
|
||||
/// Cancel the running analysis (results so far are kept).
|
||||
pub fn stop_track(&mut self) -> bool {
|
||||
if self.cancel_offloaded(crate::offload::JobKind::Track) {
|
||||
return true;
|
||||
}
|
||||
match &self.track_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.cancel();
|
||||
|
|
@ -348,6 +353,10 @@ impl Session {
|
|||
return Err("a track analysis is already running".into());
|
||||
}
|
||||
self.poll_track();
|
||||
if !wait && self.offloads() {
|
||||
let Work { comp, layer, settings, points, times, .. } = work;
|
||||
return self.offload_analysis(crate::offload::WorkerJob::Track { comp, layer, tracker, direction, settings, points, times });
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
self.history.undo.push(("Analyze Track".into(), self.project.clone()));
|
||||
self.history.redo.clear();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
|
||||
//! automatically too).
|
||||
|
||||
use crate::offload::{JobKind, rate};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
|
|
@ -232,6 +233,7 @@ fn run_work(w: WarpWork, shared: &WarpShared) {
|
|||
s.done = k as u64 + 1;
|
||||
s.elapsed = t0.elapsed().as_secs_f64();
|
||||
s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 };
|
||||
crate::offload::report(s.done, s.total);
|
||||
}
|
||||
lock(&shared.state).step = 2;
|
||||
let lt0 = layer.layer_time(w.times[0]).seconds();
|
||||
|
|
@ -264,21 +266,29 @@ pub(crate) fn analysis_times(comp: &effectcraft_project::Comp, layer: &Layer) ->
|
|||
|
||||
impl Session {
|
||||
pub fn is_warp_analyzing(&self) -> bool {
|
||||
self.warp_job.as_ref().is_some_and(|j| !j.is_finished())
|
||||
self.warp_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::Warp).is_some()
|
||||
}
|
||||
|
||||
/// Progress of the running (or just finished) analysis.
|
||||
pub fn warp_progress(&self) -> Option<WarpProgress> {
|
||||
self.warp_job.as_ref().map(|j| j.progress())
|
||||
self.warp_job.as_ref().map(|j| j.progress()).or_else(|| {
|
||||
self.offloaded(JobKind::Warp).map(|j| {
|
||||
let p = &j.progress;
|
||||
WarpProgress { step: 1, done: p.done, total: p.total, elapsed: p.elapsed, fps: rate(p.done, p.elapsed), ..Default::default() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The effect being analysed: (comp, layer, effect uid).
|
||||
pub fn warp_target(&self) -> Option<(ItemId, LayerId, Uid)> {
|
||||
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect))
|
||||
self.warp_job.as_ref().map(|j| (j.comp, j.layer, j.effect)).or_else(|| self.offloaded(JobKind::Warp).map(|j| (j.comp, j.layer, j.uid)))
|
||||
}
|
||||
|
||||
/// Cancel the running analysis (nothing is written).
|
||||
pub fn stop_warp(&mut self) -> bool {
|
||||
if self.cancel_offloaded(JobKind::Warp) {
|
||||
return true;
|
||||
}
|
||||
match &self.warp_job {
|
||||
Some(j) if !j.is_finished() => {
|
||||
j.shared.cancel.store(true, Ordering::Relaxed);
|
||||
|
|
@ -325,6 +335,11 @@ impl Session {
|
|||
times,
|
||||
};
|
||||
let key = signature(&self.project, comp, l);
|
||||
if !wait && self.offloads() {
|
||||
drop(work);
|
||||
self.offload_analysis(crate::offload::WorkerJob::Warp { comp, layer, effect })?;
|
||||
return Ok(n);
|
||||
}
|
||||
let wait = wait || cfg!(target_arch = "wasm32");
|
||||
let shared = Arc::new(WarpShared::default());
|
||||
let thread = if wait {
|
||||
|
|
@ -391,7 +406,7 @@ impl Session {
|
|||
/// re-analyses in the background, like After Effects.
|
||||
pub fn poll_warp(&mut self, auto: bool) -> bool {
|
||||
let changed = self.poll_warp_job();
|
||||
if auto && self.warp_job.is_none() {
|
||||
if auto && self.warp_job.is_none() && self.offloaded(JobKind::Warp).is_none() {
|
||||
while let Some((c, l, e)) = self.warp_pending.first().copied() {
|
||||
self.warp_pending.remove(0);
|
||||
if self.start_warp(c, l, e, false).is_ok() {
|
||||
|
|
|
|||
|
|
@ -484,6 +484,41 @@ impl<'g> Enc<'g> {
|
|||
Some(out)
|
||||
}
|
||||
|
||||
/// [`Enc::read_texture`] without waiting for the GPU (the browser's main thread can't): `done`
|
||||
/// gets the bytes once the copy is mapped (from the browser's event loop on the web; from
|
||||
/// a later device poll natively).
|
||||
pub fn read_texture_async(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32, done: impl FnOnce(Option<Vec<u8>>) + wgpu::WasmNotSend + 'static) {
|
||||
let row = w * bpp;
|
||||
let padded = row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("readback-async"),
|
||||
size: padded as u64 * h as u64,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.encoder().copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded), rows_per_image: Some(h) } },
|
||||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
self.submit();
|
||||
let b = buf.clone();
|
||||
buf.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let out = r.ok().and_then(|_| {
|
||||
let view = b.get_mapped_range(..).ok()?;
|
||||
let mut out = Vec::with_capacity((row * h) as usize);
|
||||
for y in 0..h as usize {
|
||||
out.extend_from_slice(&view[y * padded as usize..y * padded as usize + row as usize]);
|
||||
}
|
||||
Some(out)
|
||||
});
|
||||
b.unmap();
|
||||
done(out);
|
||||
});
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = self.g.device.poll(wgpu::PollType::Poll);
|
||||
}
|
||||
|
||||
/// A data buffer for kernels that read tables (curves).
|
||||
pub fn data(&self, v: &[f32]) -> wgpu::Buffer {
|
||||
let mut bytes = Vec::with_capacity(v.len().max(4) * 4);
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@ impl Gpu {
|
|||
Enc::new(&self.ctx).read_texture(&f.texture, f.width, f.height, 4)
|
||||
}
|
||||
|
||||
/// [`Gpu::read_display`] without blocking (the browser's main thread): `done` gets the bytes
|
||||
/// once the GPU has them.
|
||||
pub fn read_display_async(&self, f: &DisplayFrame, done: impl FnOnce(Option<Vec<u8>>) + wgpu::WasmNotSend + 'static) {
|
||||
Enc::new(&self.ctx).read_texture_async(&f.texture, f.width, f.height, 4, done);
|
||||
}
|
||||
|
||||
/// Render a frame on the GPU and read it back (`None` = not handled).
|
||||
pub fn render(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
|
||||
if !self.ctx.can_readback() {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
//! Real-time audio preview.
|
||||
//!
|
||||
//! The frontend owns no audio API: the native app supplies an [`AudioDevice`] (cpal) through
|
||||
//! [`crate::Hooks::audio_device`]. During preview playback an [`AudioPlayback`] runs a feeder
|
||||
//! thread that mixes the composition ahead of the device with the same mixdown export uses
|
||||
//! [`crate::Hooks::audio_device`], the web app one on Web Audio. During preview playback an
|
||||
//! [`AudioPlayback`] runs a feeder thread (wasm32: [`AudioPlayback::pump`] every UI frame) that
|
||||
//! mixes the composition ahead of the device with the same mixdown export uses
|
||||
//! (`effectcraft_render::audio::mix_comp`: Audio switch, solo, Audio Levels, audio effects) and
|
||||
//! pushes it into an [`AudioFeed`]; the device callback pulls from the feed.
|
||||
//!
|
||||
|
|
@ -35,6 +36,9 @@ pub trait AudioDevice: Send {
|
|||
fn latency_frames(&self) -> u64 {
|
||||
0
|
||||
}
|
||||
/// Called every UI frame while playing: devices without an audio thread of their own (Web
|
||||
/// Audio) move queued samples to the output here.
|
||||
fn pump(&mut self) {}
|
||||
}
|
||||
|
||||
/// Opens the default output device (`None` when there is none).
|
||||
|
|
@ -160,10 +164,18 @@ pub struct AudioPlayback {
|
|||
device: Box<dyn AudioDevice>,
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
/// wasm32 (no threads): what the feeder thread would own, run by [`AudioPlayback::pump`].
|
||||
feeder: Option<(RenderSource, ItemId, i64)>,
|
||||
pub rate: u32,
|
||||
pub span: PlaySpan,
|
||||
}
|
||||
|
||||
/// Audio kept queued ahead of the device: ~200 ms (a feeder thread tops it up continuously);
|
||||
/// wasm32 tops it up once per UI frame, so it keeps ~350 ms to ride out slow frames.
|
||||
fn queue_target(rate: u32) -> usize {
|
||||
if cfg!(target_arch = "wasm32") { rate as usize * 7 / 20 } else { rate as usize / 5 }
|
||||
}
|
||||
|
||||
impl AudioPlayback {
|
||||
/// Start mixing `comp` over `span` into `device`.
|
||||
pub fn start(
|
||||
|
|
@ -185,13 +197,18 @@ impl AudioPlayback {
|
|||
for _ in 0..prime {
|
||||
cursor = feed_block(&src, comp, &feed, &span, cursor, rate);
|
||||
}
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
let mut p = AudioPlayback { feed: feed.clone(), device, stop, thread: None, feeder: Some((src, comp, cursor)), rate, span };
|
||||
p.device.start(feed)?;
|
||||
p.pump();
|
||||
return Ok(p);
|
||||
}
|
||||
let thread = {
|
||||
let (feed, stop) = (feed.clone(), stop.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("ec-audio-feed".into())
|
||||
.spawn(move || {
|
||||
// Keep ~200 ms queued ahead of the device.
|
||||
let target = rate as usize / 5;
|
||||
let target = queue_target(rate);
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if feed.queued_frames() < target && !feed.finished.load(Ordering::Relaxed) {
|
||||
cursor = feed_block(&src, comp, &feed, &span, cursor, rate);
|
||||
|
|
@ -203,7 +220,19 @@ impl AudioPlayback {
|
|||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
device.start(feed.clone())?;
|
||||
Ok(AudioPlayback { feed, device, stop, thread: Some(thread), rate, span })
|
||||
Ok(AudioPlayback { feed, device, stop, thread: Some(thread), feeder: None, rate, span })
|
||||
}
|
||||
|
||||
/// Every UI frame while playing: on wasm32 mix ahead into the feed (what the feeder thread
|
||||
/// does elsewhere), then let the device take what it needs.
|
||||
pub fn pump(&mut self) {
|
||||
if let Some((src, comp, cursor)) = &mut self.feeder {
|
||||
let target = queue_target(self.rate);
|
||||
while self.feed.queued_frames() < target && !self.feed.finished.load(Ordering::Relaxed) {
|
||||
*cursor = feed_block(src, *comp, &self.feed, &self.span, *cursor, self.rate);
|
||||
}
|
||||
}
|
||||
self.device.pump();
|
||||
}
|
||||
|
||||
/// The comp sample index audible now.
|
||||
|
|
|
|||
|
|
@ -118,9 +118,11 @@ pub struct RenderSource {
|
|||
pub layer_cache: Arc<LayerCache>,
|
||||
/// GPU compositor on the viewer's device (Mercury GPU Acceleration), if available.
|
||||
pub gpu: Option<Gpu>,
|
||||
/// Keep GPU frames on the GPU for the viewer (native egui-wgpu textures). Off by default:
|
||||
/// Show Channel / exposure / region-of-interest drawing read the CPU frame texture, so GPU
|
||||
/// frames are read back (the compositing still runs on the GPU).
|
||||
/// Keep GPU frames on the GPU for the viewer (native egui-wgpu textures). Off on the
|
||||
/// desktop: Show Channel / exposure / region-of-interest drawing read the CPU frame texture,
|
||||
/// so GPU frames are read back (the compositing still runs on the GPU). On in the browser
|
||||
/// while the viewer draws plainly, because there frames can't be read back synchronously
|
||||
/// (`EffectcraftApp::gpu_display`).
|
||||
pub gpu_display: bool,
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +177,11 @@ impl Frames {
|
|||
}
|
||||
}
|
||||
|
||||
/// The egui context (to request repaints from callbacks).
|
||||
pub fn context(&self) -> Option<egui::Context> {
|
||||
self.ctx.clone()
|
||||
}
|
||||
|
||||
pub fn get(&self, k: &FrameKey) -> Option<FrameImage> {
|
||||
self.cache.lock().ok()?.map.get(k).cloned()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,6 +138,8 @@ pub struct EffectcraftApp {
|
|||
/// Last displayed image (Info panel colour, eyedroppers, histograms). GPU frames fill it on
|
||||
/// demand ([`EffectcraftApp::viewer_pixels`]).
|
||||
pub(crate) viewer_image: Option<Arc<egui::ColorImage>>,
|
||||
/// wasm32: an asynchronous readback of a GPU viewer frame (key asked for, result).
|
||||
viewer_readback: (Option<FrameKey>, Arc<std::sync::Mutex<Option<(FrameKey, Arc<egui::ColorImage>)>>>),
|
||||
/// egui-wgpu's device, once the first frame arrives (desktop and WebGPU).
|
||||
pub(crate) wgpu: Option<eframe::egui_wgpu::RenderState>,
|
||||
/// The GPU compositor on that device (None: no usable adapter → CPU only).
|
||||
|
|
@ -198,6 +200,7 @@ impl EffectcraftApp {
|
|||
viewer_native: None,
|
||||
viewer_shown: None,
|
||||
viewer_image: None,
|
||||
viewer_readback: Default::default(),
|
||||
wgpu: None,
|
||||
gpu: None,
|
||||
gpu_checked: false,
|
||||
|
|
@ -323,10 +326,22 @@ impl EffectcraftApp {
|
|||
expr: self.session.expr.clone(),
|
||||
layer_cache: self.session.layer_cache.clone(),
|
||||
gpu: self.gpu.clone(),
|
||||
gpu_display: false,
|
||||
gpu_display: self.gpu_display(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep GPU-composited viewer frames on the GPU (drawn from their texture). The browser can't
|
||||
/// read frames back (its thread can't wait for the GPU), so there the viewer shows GPU frames
|
||||
/// directly whenever it draws them plainly (RGB, no exposure, no region of interest); the
|
||||
/// desktop reads them back (see [`frames::RenderSource::gpu_display`]).
|
||||
fn gpu_display(&self) -> bool {
|
||||
let v = &self.session.state.viewer;
|
||||
cfg!(target_arch = "wasm32")
|
||||
&& v.channel == effectcraft_engine::viewer::Channel::Rgb
|
||||
&& v.exposure == 0.0
|
||||
&& self.session.state.region_of_interest.is_none()
|
||||
}
|
||||
|
||||
/// Set up the GPU compositor on egui-wgpu's device (once). Without an adapter that runs
|
||||
/// compute shaders (WebGL2, software GL) everything stays on the CPU.
|
||||
pub(crate) fn init_gpu(&mut self, frame: &eframe::Frame) {
|
||||
|
|
@ -358,14 +373,38 @@ impl EffectcraftApp {
|
|||
|
||||
/// The viewer's current pixels as 8-bit premultiplied RGBA, reading a GPU frame back the
|
||||
/// first time something asks for it.
|
||||
///
|
||||
/// The browser can't wait for the GPU: there the first call starts an asynchronous readback
|
||||
/// and returns `None`; a later call (the next frame) has the pixels.
|
||||
pub fn viewer_pixels(&mut self) -> Option<Arc<egui::ColorImage>> {
|
||||
if self.viewer_image.is_none()
|
||||
&& let (Some((_, _, f)), Some(g)) = (&self.viewer_native, &self.gpu)
|
||||
&& self.viewer_shown.as_ref().map(|s| s.1) == self.viewer_native.as_ref().map(|n| n.1)
|
||||
&& let Some(bytes) = g.read_display(f)
|
||||
{
|
||||
let to_image = |bytes: Vec<u8>, w: u32, h: u32| {
|
||||
let px = bytes.chunks_exact(4).map(|c| egui::Color32::from_rgba_premultiplied(c[0], c[1], c[2], c[3])).collect();
|
||||
self.viewer_image = Some(Arc::new(egui::ColorImage::new([f.width as usize, f.height as usize], px)));
|
||||
Arc::new(egui::ColorImage::new([w as usize, h as usize], px))
|
||||
};
|
||||
if self.viewer_image.is_none()
|
||||
&& let (Some((_, key, f)), Some(g)) = (&self.viewer_native, &self.gpu)
|
||||
&& self.viewer_shown.as_ref().map(|s| s.1) == Some(*key)
|
||||
{
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
let ready = self.viewer_readback.1.lock().ok().and_then(|mut r| r.take_if(|r| r.0 == *key));
|
||||
if let Some((_, img)) = ready {
|
||||
self.viewer_image = Some(img);
|
||||
} else if self.viewer_readback.0 != Some(*key) {
|
||||
self.viewer_readback.0 = Some(*key);
|
||||
let (slot, k, (w, h)) = (self.viewer_readback.1.clone(), *key, (f.width, f.height));
|
||||
let ctx = self.frames.context();
|
||||
g.read_display_async(f, move |bytes| {
|
||||
if let (Some(b), Ok(mut s)) = (bytes, slot.lock()) {
|
||||
*s = Some((k, to_image(b, w, h)));
|
||||
}
|
||||
if let Some(c) = ctx {
|
||||
c.request_repaint();
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if let Some(bytes) = g.read_display(f) {
|
||||
self.viewer_image = Some(to_image(bytes, f.width, f.height));
|
||||
}
|
||||
}
|
||||
self.viewer_image.clone()
|
||||
}
|
||||
|
|
@ -532,7 +571,8 @@ impl EffectcraftApp {
|
|||
queued += 1;
|
||||
}
|
||||
}
|
||||
if let Some(a) = &self.audio {
|
||||
if let Some(a) = &mut self.audio {
|
||||
a.pump();
|
||||
self.meter.update(a.feed.take_peaks(), now);
|
||||
} else if self.meter.active() {
|
||||
self.meter.update([0.0; 2], now);
|
||||
|
|
@ -738,6 +778,11 @@ impl EffectcraftApp {
|
|||
self.session.poll_mask_track();
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
}
|
||||
// Analyses running in a worker (the web app).
|
||||
if !self.session.offloaded.is_empty() {
|
||||
self.session.poll_offload();
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
// Warp Stabilizer: finish analyses and start queued (re-)analyses in the background.
|
||||
if self.session.warp_job.is_some() || !self.session.warp_pending.is_empty() {
|
||||
self.session.poll_warp(true);
|
||||
|
|
|
|||
|
|
@ -361,6 +361,20 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
|
|||
painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, "Snapshot", Tokens::ui(11.0), SNAP_COLOR);
|
||||
return;
|
||||
}
|
||||
// A GPU frame (the web viewer): drawn straight from its texture; Show Channel / exposure
|
||||
// read it back first (asynchronously in the browser: from the next frame on).
|
||||
let gpu = matches!((&app.viewer_shown, &app.viewer_native), (Some(s), Some(n)) if s.0 == n.0 && s.1 == n.1 && n.1.comp == cid.0);
|
||||
let rect = if gpu {
|
||||
let id = app.viewer_shown.map(|s| s.0).expect("shown");
|
||||
if plain || app.viewer_pixels().is_none() {
|
||||
if !plain {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
painter.image(id, comp_rect, uv, Color32::WHITE);
|
||||
return;
|
||||
}
|
||||
comp_rect
|
||||
} else {
|
||||
let Some((tex, k)) = app.viewer_tex.clone() else { return };
|
||||
if k.comp != cid.0 {
|
||||
return;
|
||||
|
|
@ -374,6 +388,8 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
|
|||
painter.image(tex.id(), rect, uv, Color32::WHITE);
|
||||
return;
|
||||
}
|
||||
rect
|
||||
};
|
||||
let Some(src) = app.viewer_image.clone() else { return };
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
|
|
|||
|
|
@ -154,7 +154,8 @@ chain with one upload and one readback. Tests render scenes on both paths and co
|
|||
(≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an adapter. The desktop viewer builds the
|
||||
`Gpu` on egui-wgpu's device and shows frames from GPU textures without reading them back
|
||||
(`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. On the web
|
||||
(WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU.
|
||||
(WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU (the
|
||||
Info panel's pixel readout reads GPU frames back asynchronously).
|
||||
|
||||
**Colour and bit depth** (`crates/render/src/color.rs`, `crates/color/src/space.rs`). Pixels are
|
||||
`f32`, but 8 and 16 bpc projects clamp and quantise each layer after its source and masks and
|
||||
|
|
@ -259,6 +260,14 @@ inspect and click the interface. See [agents.md](agents.md) and
|
|||
|
||||
Settings (`engine::prefs`), keyboard shortcut presets (`engine::shortcuts`) and auto-save /
|
||||
crash recovery (`engine::autosave`) live in the engine too; they persist through a
|
||||
`ConfigStore` the frontend provides (a directory on the desktop, `localStorage` on the web).
|
||||
`ConfigStore` the frontend provides (a directory on the desktop; the Origin Private File System,
|
||||
or IndexedDB, on the web). Auto-saves go through the store's `FileOps` (the file system by
|
||||
default, the browser storage on the web).
|
||||
|
||||
Renders and analyses run on background threads on the desktop. Where there are no threads (the
|
||||
browser), `engine::offload` sends them to a second engine instance instead: the session's
|
||||
`Offload` ships the serialized project, the footage it reads and the job (`WorkerRequest`) to a
|
||||
Web Worker, and the worker's replies (progress, item status, the analysed property group) are
|
||||
applied on the UI thread.
|
||||
The shortcut dispatcher and the menus read the active preset. See
|
||||
[preferences.md](preferences.md).
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ Timeline column/search depth, the Home screen and every After Effects workspace
|
|||
| Compositions | 72% | 6.0 | Essential Graphics panel (the marker dialog and Composition Flowchart landed) |
|
||||
| Animation | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) |
|
||||
| Text | ≈ 86% | 1.3 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no extruded strokes (extruded, bevelled text landed in M7.6); per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) |
|
||||
| Web | 60% | 2.5 | threads, browser storage, audio |
|
||||
| Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) |
|
||||
| 3D | 72% | 12.0 | 3D camera tracker, multi-view layouts, Advanced 3D motion blur and blend modes, cameras/lights from models; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) landed in M7.4–M7.6 |
|
||||
| Effects | ≈ 75% | 5.0 | GPU versions of more effects and the 3D Camera Tracker (every other After Effects effect exists since M9.11 and M12.5) |
|
||||
| Interface | 70% | 6.0 | Timeline horizontal scrolling of many columns, a richer Learn area (native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
|
|
|
|||
167
docs/web.md
167
docs/web.md
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
`apps/effectcraft-web` runs the same engine and egui UI as the desktop app in the browser: the
|
||||
workspace compiled to `wasm32-unknown-unknown`, started by eframe's web runner on WebGPU (WebGL2
|
||||
fallback). It is a static site: one `.wasm`, its `wasm-bindgen` JavaScript glue, `index.html` and an
|
||||
icon. Nothing is uploaded anywhere; projects, media and renders stay in the page and on the user's
|
||||
machine.
|
||||
fallback). It is a static site: one `.wasm`, its `wasm-bindgen` JavaScript glue, `index.html`, a
|
||||
few small scripts (worker, audio worklet, service worker), a web manifest and icons. Nothing is
|
||||
uploaded anywhere: projects, media, settings and renders stay in the browser (its private storage)
|
||||
and on the user's machine.
|
||||
|
||||
## Build and run
|
||||
|
||||
|
|
@ -15,48 +16,136 @@ machine.
|
|||
| wasm-opt (optional) | any recent binaryen | `brew install binaryen`; used by `cargo xtask web` when found |
|
||||
|
||||
```sh
|
||||
cargo xtask web # release build → <target>/web/dist (index.html, effectcraft_web.js, effectcraft_web_bg.wasm, favicon.svg)
|
||||
cargo xtask web # release build → <target>/web/dist
|
||||
cargo xtask web --dev # unoptimised build (faster to compile, slow to run)
|
||||
cargo xtask web --serve 8765 # build, then serve dist on http://127.0.0.1:8765/
|
||||
```
|
||||
|
||||
`<target>` is `$CARGO_TARGET_DIR` or `target`. `--serve` is a tiny localhost-only static server that
|
||||
sends `Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy`, so the page is cross-origin
|
||||
`<target>` is `$CARGO_TARGET_DIR` or `target`. `dist` holds `index.html`, `effectcraft_web.js` +
|
||||
`effectcraft_web_bg.wasm` (+ `snippets/`, the browser glue `js/host.js`), `worker.js`,
|
||||
`audio-worklet.js`, `sw.js` (its precache list and version filled in by the build),
|
||||
`manifest.webmanifest`, `favicon.svg` and `icon-256.png` / `icon-512.png` (copied from
|
||||
`assets/app-icon`). `--serve` is a tiny localhost-only static server that sends
|
||||
`Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy`, so the page is cross-origin
|
||||
isolated like a production deployment should be. Any static server works
|
||||
(`python3 -m http.server -d target/web/dist 8765`); it must serve `.wasm` as `application/wasm`.
|
||||
Service workers need a secure context: `https://`, or `http://localhost` / `127.0.0.1`.
|
||||
|
||||
The release `.wasm` is about 31 MB before `wasm-opt` (20 MB code: the effects, codecs, the
|
||||
expression engine and the UI; 8.5 MB data, mostly the bundled fonts; 2 MB function names for
|
||||
readable panics). Served compressed it is a fraction of that.
|
||||
The release `.wasm` is about 38 MB before `wasm-opt` (code: the effects, codecs, the expression
|
||||
engine and the UI; data, mostly the bundled fonts; function names for readable panics). Served
|
||||
compressed it is a fraction of that, and the service worker caches it after the first visit.
|
||||
|
||||
The web crate is empty on non-wasm targets, so `cargo test --workspace` and clippy are unaffected;
|
||||
`cargo xtask wasm` (part of `cargo xtask ci`) checks it, with every L0–L4 crate and
|
||||
`effectcraft-ui-egui`, for `wasm32-unknown-unknown`.
|
||||
On other targets the web crate holds only its portable storage model (`store.rs`, unit-tested by
|
||||
`cargo test --workspace`); `cargo xtask wasm` (part of `cargo xtask ci`) checks the whole crate,
|
||||
with every L0–L4 crate and `effectcraft-ui-egui`, for `wasm32-unknown-unknown`.
|
||||
|
||||
URL flags: `?empty` (start without the demo project), `?home` (show the start screen).
|
||||
URL flags: `?empty` (a blank project; the session is not restored or recorded), `?demo` (the demo
|
||||
project instead of the last session), `?home` (show the start screen), `?storage=indexeddb` /
|
||||
`?storage=memory` (force a storage backend), `?noworkers` (render and analyse on the page's
|
||||
thread), `?nosw` (don't register the service worker).
|
||||
|
||||
## How the desktop pieces map to the browser
|
||||
|
||||
| Desktop | Web |
|
||||
|---|---|
|
||||
| frame render thread pool (`ui-egui/src/frames.rs`) | `Frames::pump`: queued frames render on the UI thread after each egui frame, most important first (the viewer's frame, then prefetch), within 40 ms (24 ms while playing) |
|
||||
| Render Queue on a background thread | `Session::start_render` always runs inline on wasm32: the page waits until the render is done |
|
||||
| Render Queue on a background thread | a Web Worker running a second engine instance (below); progress streams back, the page never waits |
|
||||
| tracker / mask tracker / Warp Stabilizer / Roto Brush Freeze threads | the same workers; the analysed property group comes back as one undo step |
|
||||
| `rayon` parallel loops (raster, effects, export batches) | rayon's global pool falls back to the calling thread when threads are unavailable: same code, serial |
|
||||
| `std::time::Instant` / `SystemTime` (panic on wasm32) | `web-time` (re-exports `std::time` on native) |
|
||||
| `std::fs` project reads/writes (`FsServices`) | `files::WebServices`: an in-memory file table; saving downloads the `.ecproj` |
|
||||
| `std::fs` project reads/writes (`FsServices`) | `files::WebServices`: the virtual file table, persisted (below); saving also downloads the `.ecproj` |
|
||||
| config directory: settings, shortcut presets, recent projects, the recovery sentinel | `store::WebConfig` in browser storage |
|
||||
| auto-save folder | `/EffectCraft Auto-Save/` in browser storage (`ConfigStore::files`) |
|
||||
| media reads (`MediaPool`, `probe`) | `files::WebImporter`: bytes from the file table, handed to `MediaPool::add_bytes` / `probe_bytes` |
|
||||
| export writes (`effectcraft-export`) | the job's `sink` (`effectcraft_host::FileExporter { sink }`): files land in the table and download after the render; several files (an image sequence) download as one stored `.zip` |
|
||||
| rfd file dialogs | `<input type=file>` for File ▸ Open / Import; drop files anywhere on the page (`.ecproj` opens, everything else imports); "Save As" / "Output To" pick a download name |
|
||||
| system fonts | not scanned; the bundled fonts (Inter, Noto Serif, JetBrains Mono) are always there |
|
||||
| TCP control channel / MCP | `window.effectcraft` (below) |
|
||||
| audio output | none yet (if `cpal` lands for desktop playback it must stay behind a cfg/feature off wasm32; a WebAudio backend would replace it) |
|
||||
| cpal audio output | Web Audio (below) |
|
||||
| GPU compositor on the desktop's wgpu device | the same compositor on eframe's WebGPU device (below) |
|
||||
|
||||
### Threads
|
||||
### Persistence
|
||||
|
||||
wasm threads need a cross-origin isolated page **and** a wasm build with atomics (a nightly
|
||||
`build-std` toolchain), so this build is single-threaded: frames, effects and exports run on the UI
|
||||
thread. A long Render Queue job freezes the page until it finishes; keep web renders short or
|
||||
small (resolution, time span).
|
||||
Everything that lives in files on the desktop lives in the browser's **Origin Private File
|
||||
System** (OPFS); where OPFS can't be written from the page (older Safari), **IndexedDB**; with
|
||||
neither (some private windows), memory only. `effectcraft.info().storage.backend` says which.
|
||||
The page asks for persistent storage (`navigator.storage.persist()`), which browsers grant to
|
||||
installed apps, so the store isn't evicted under storage pressure.
|
||||
|
||||
The engine's storage traits are synchronous and the browser's are not, so the app works on an
|
||||
in-memory mirror (`store.rs`): every stored entry is read before the app starts, reads and writes
|
||||
hit the mirror, and changes flush in the background (one write per changed key, in order; OPFS
|
||||
writes are atomic: a swap file replaced on close). Keys:
|
||||
|
||||
| Key | What |
|
||||
|---|---|
|
||||
| `config/prefs.json`, `config/shortcuts.json` | Settings (with File ▸ Open Recent's list) and keyboard shortcut presets |
|
||||
| `config/session.lock` | the crash-recovery sentinel |
|
||||
| `config/session.ecproj`, `config/session.json` | the session snapshot: the open project and editor state (active comp, time, selection), written at most once a second while they change |
|
||||
| `files/<path>` | the file table: imported media, projects saved with File ▸ Save / Save As or `saveToBrowser`, auto-saves |
|
||||
|
||||
On the next visit the app reopens the snapshot (unsaved changes stay unsaved: the project is
|
||||
dirty), registers every stored media file with the media pool so its footage decodes, and File ▸
|
||||
Open Recent reopens saved projects from storage. Without a snapshot (first visit) it opens the
|
||||
demo project. Render outputs are not stored: they download.
|
||||
|
||||
### Background jobs: Web Workers
|
||||
|
||||
wasm threads need shared memory, which needs a nightly `build-std` toolchain, so this build is
|
||||
single-threaded per instance. Long jobs run in **Web Workers** instead, each with its own instance
|
||||
of the same module (the page compiles it once and posts the `WebAssembly.Module`, so nothing is
|
||||
compiled twice) and its own memory (`crates/engine/src/offload.rs`, `src/worker.rs`):
|
||||
|
||||
1. The session's `Offload` serializes a `WorkerRequest`: the project JSON, the footage paths it
|
||||
reads, and the job (`render` with the resolved queue items and output paths; `warp`, `track`,
|
||||
`maskTrack`, `rotoFreeze` with the target and analysis parameters).
|
||||
2. `js/host.js` takes an idle worker from its pool (or starts one), sends it the footage bytes it
|
||||
doesn't have yet, then the request.
|
||||
3. The worker runs the job on a plain engine session, blocking, and posts `WorkerReply`s: render
|
||||
progress (at most every 100 ms), item started/finished, rendered files (transferred), analysis
|
||||
progress, the analysed property group, `failed`, `done`.
|
||||
4. The page applies them every frame: `Session::poll_render` updates the queue items and the
|
||||
progress bar, `Session::poll_offload` writes an analysis result as one undo step (the effect /
|
||||
tracker / mask group is replaced; other edits made meanwhile are kept) and the downloads start.
|
||||
|
||||
Stopping a job terminates its worker (a worker can't be interrupted mid-frame): the item being
|
||||
rendered becomes "User Stopped", an analysis writes nothing. `renderQueue.render {wait: true}` (the
|
||||
default for agents) and analysis commands with `wait: true` still run on the page's thread and
|
||||
return when done. Roto Brush propagation fills the session's segmentation cache rather than the
|
||||
project, so it runs on the page's thread too, as do viewer frames (`Frames::pump`).
|
||||
|
||||
### Audio
|
||||
|
||||
Audio preview goes through **Web Audio**: an AudioWorklet (`audio-worklet.js`; a
|
||||
ScriptProcessorNode where worklets are unavailable) plays interleaved stereo blocks. The mixdown
|
||||
is the desktop's (`AudioPlayback`, `mix_comp`): without a feeder thread, `AudioPlayback::pump`
|
||||
mixes ahead on every UI frame (≈ 350 ms queued) and the output device takes what keeps it
|
||||
≈ 150 ms ahead of the playhead. A/V sync follows `AudioContext.currentTime`: the playback clock
|
||||
is the frames played by the context clock minus its output latency, and the viewer shows the
|
||||
frame under the audible sample, as on the desktop. The Audio panel's meters read the same feed.
|
||||
|
||||
Browsers start an AudioContext suspended until the user interacts with the page; the app resumes
|
||||
it on the first pointer or key event, so audio plays from the first preview after any click or
|
||||
key press.
|
||||
|
||||
### GPU
|
||||
|
||||
eframe starts on WebGPU where the browser has it (WebGL2 otherwise), and the GPU compositor
|
||||
(`effectcraft-gpu`) runs on the same device: viewer frames are composited by compute shaders and
|
||||
drawn straight from their texture, with no readback. Steps that need the CPU mid-render (3D runs,
|
||||
adjustment layers, CPU-only effects) fall back to the CPU renderer for that frame, because the
|
||||
page's thread can't wait for a GPU readback. The Info panel's pixel readout and the eyedroppers
|
||||
read GPU frames back asynchronously (`Gpu::read_display_async`: the pixels arrive a frame
|
||||
later). On WebGL2, or without a usable adapter, everything renders on the CPU.
|
||||
`effectcraft.info().gpu` reports `{compositor, viewerOnGpu}`.
|
||||
|
||||
### Offline and install (PWA)
|
||||
|
||||
`manifest.webmanifest` (name, icons, standalone display) makes the app installable; `sw.js`
|
||||
precaches every file of the build into a cache named after the build's hash, serves them
|
||||
cache-first (the cached responses keep the server's COOP/COEP headers, so the page stays
|
||||
cross-origin isolated offline), and drops older caches when a new build activates. After the
|
||||
first visit the app loads without a network.
|
||||
|
||||
## `window.effectcraft`: the agent / test API
|
||||
|
||||
|
|
@ -70,34 +159,52 @@ with the method's `result`, errors reject with the message.
|
|||
| `effectcraft.commands()` / `effectcraft.inspect()` | `engine.commands` / `ui.inspect` |
|
||||
| `effectcraft.renderFrame(params)` | `render.frame` with `base64: true`: `{comp, time, width, height, png}` |
|
||||
| `effectcraft.screenshot(params)` | `ui.screenshot`; without `path` the PNG comes back inline as `png` (base64) |
|
||||
| `effectcraft.addFile(fileOrUrl, name?)` | put a `File`/`Blob` (or fetched URL) into the file table and open (`.ecproj`) or import it; resolves with its path |
|
||||
| `effectcraft.addFile(fileOrUrl, name?)` | put a `File`/`Blob` (or fetched URL) into the file table (stored) and open (`.ecproj`) or import it; resolves with its path |
|
||||
| `effectcraft.files()` / `effectcraft.readFile(path)` | the file table `[{path, size}]` / a file's bytes (`Uint8Array`), e.g. a render |
|
||||
| `effectcraft.info()` | graphics backend, version, `crossOriginIsolated`, load timings |
|
||||
| `effectcraft.saveToBrowser(path?)` | save the project to browser storage without downloading it (default: its path, or `/<name>.ecproj`); resolves with `{path, bytes}` |
|
||||
| `effectcraft.listStored()` | `{backend, usage, quota, persisted, pending, files: [{path, size, modified}], config: [name]}` |
|
||||
| `effectcraft.removeStored(path)` | delete a stored file |
|
||||
| `effectcraft.flush()` | resolves once every change is written to browser storage |
|
||||
| `effectcraft.info()` | graphics backend, `gpu`, `storage`, `audio` (`{state, sampleRate, backend, posted, played, underruns}`), `workers`, `restored`, `webgpu`, `serviceWorker`, version, `crossOriginIsolated`, load timings |
|
||||
|
||||
`window.effectcraftLoad` holds `{wasmMs, readyMs}` (module fetch + compile, and until the app runs).
|
||||
|
||||
```js
|
||||
await effectcraft.execute("layer.newSolid", {color: "#ff8800"});
|
||||
await effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", resolution: 0.25}});
|
||||
await effectcraft.execute("renderQueue.render", {}); // the GIF downloads
|
||||
await effectcraft.execute("renderQueue.render", {wait: false}); // renders in a worker; the GIF downloads
|
||||
await effectcraft.saveToBrowser("/my-project.ecproj"); // File ▸ Open Recent has it after a reload
|
||||
```
|
||||
|
||||
## Browser test
|
||||
|
||||
`apps/effectcraft-web/tests/smoke.mjs` drives headless Chrome over the DevTools protocol (Node ≥ 22,
|
||||
no npm packages): load, the demo comp in the viewer, `render.frame`, Render Queue GIF and PNG
|
||||
sequence (`.zip`) downloads, Save As and re-opening the saved project. It writes screenshots and
|
||||
`report.json`:
|
||||
no npm packages): load, the GPU path (WebGPU → GPU compositor, viewer on the GPU), the demo comp
|
||||
in the viewer, `render.frame`, Render Queue GIF and PNG sequence (`.zip`) downloads, a background
|
||||
render in a worker while measuring the page's event-loop gaps (must stay under 400 ms) and its
|
||||
progress, the AudioContext starting on a (simulated) key press and a tone playing through it
|
||||
with the meters moving, Save As and re-opening the saved project, persistence across a reload
|
||||
(session snapshot, a stored project, imported media, a setting, Open Recent) and an offline
|
||||
reload through the service worker. It writes screenshots and `report.json`:
|
||||
|
||||
```sh
|
||||
cargo xtask web --serve 8765 &
|
||||
node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out target/web/smoke
|
||||
```
|
||||
|
||||
Native unit tests cover the storage model (`apps/effectcraft-web/src/store.rs`: write
|
||||
coalescing, file table, `ConfigStore` / `FileOps`, auto-save and crash recovery through the
|
||||
browser store) and the worker protocol (`crates/engine/src/offload.rs`: serde round trips of
|
||||
every request and reply, a render through an in-process offload).
|
||||
|
||||
## Gaps
|
||||
|
||||
- Single-threaded (above); Render Queue renders block the page.
|
||||
- No audio playback.
|
||||
- Imported media live in memory for the session only (nothing is persisted; reload = fresh start).
|
||||
- Viewer frames, RAM preview and Roto Brush propagation still render on the page's thread (one
|
||||
frame at a time between UI frames).
|
||||
- GPU effects run only in the desktop GPU path: in the browser, effects run on the CPU (inside
|
||||
workers for renders) because the page's thread can't wait for mid-render readbacks.
|
||||
- Cancelling an analysis drops its partial result (the worker is terminated).
|
||||
- Stored media count against the origin's storage quota; `effectcraft.listStored()` shows usage,
|
||||
`removeStored` frees it (there is no storage manager in the UI yet).
|
||||
- `engine.execute` rejects the spread Render Settings / Output Module keys of `renderQueue.add`
|
||||
(`resolution`, `timeSpan`…) through its strict parameter check; use `ui.menu.invoke` as above.
|
||||
|
|
|
|||
|
|
@ -285,8 +285,14 @@ fn web(args: &[String]) -> Result<(), String> {
|
|||
std::fs::copy(e.path(), dist.join(&name)).map_err(|e| format!("{name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
// PWA icons: the app icon (assets/app-icon, attributed there).
|
||||
for n in [256, 512] {
|
||||
let src = format!("assets/app-icon/hicolor/{n}x{n}/apps/ai.storyteller.effectcraft.png");
|
||||
std::fs::copy(&src, dist.join(format!("icon-{n}.png"))).map_err(|e| format!("{src}: {e}"))?;
|
||||
}
|
||||
service_worker(&dist)?;
|
||||
let size = |p: &std::path::Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
|
||||
let total: u64 = std::fs::read_dir(&dist).map_err(|e| e.to_string())?.flatten().map(|e| size(&e.path())).sum();
|
||||
let total: u64 = dist_files(&dist).iter().map(|f| size(&dist.join(f))).sum();
|
||||
println!("web: {} ({:.1} MB wasm, {:.1} MB total)", dist.display(), size(&bg) as f64 / 1e6, total as f64 / 1e6);
|
||||
if let Some(port) = serve {
|
||||
serve_dir(&dist, port)?;
|
||||
|
|
@ -294,6 +300,42 @@ fn web(args: &[String]) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Every file under `dist`, as `/`-separated relative paths, sorted.
|
||||
fn dist_files(dist: &std::path::Path) -> Vec<String> {
|
||||
fn walk(dir: &std::path::Path, rel: &str, out: &mut Vec<String>) {
|
||||
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let r = if rel.is_empty() { name } else { format!("{rel}/{name}") };
|
||||
if e.path().is_dir() {
|
||||
walk(&e.path(), &r, out);
|
||||
} else {
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut v = vec![];
|
||||
walk(dist, "", &mut v);
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// Fill the service worker's precache list and version (a hash of the build's files).
|
||||
fn service_worker(dist: &std::path::Path) -> Result<(), String> {
|
||||
let sw = dist.join("sw.js");
|
||||
let text = std::fs::read_to_string(&sw).map_err(|e| format!("sw.js: {e}"))?;
|
||||
let files: Vec<String> = dist_files(dist).into_iter().filter(|f| f != "sw.js").collect();
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for f in &files {
|
||||
for b in f.bytes().chain(std::fs::read(dist.join(f)).map_err(|e| format!("{f}: {e}"))?) {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
let list: Vec<String> = std::iter::once("./".to_string()).chain(files.iter().map(|f| format!("./{f}"))).map(|f| format!("{f:?}")).collect();
|
||||
let text = text.replace("__EC_VERSION__", &format!("{h:016x}")).replace("__EC_FILES__", &format!("[{}]", list.join(", ")));
|
||||
std::fs::write(&sw, text).map_err(|e| format!("sw.js: {e}"))
|
||||
}
|
||||
|
||||
/// A tiny static file server for the web build (localhost only). Sends the cross-origin
|
||||
/// isolation headers so `crossOriginIsolated` is true, like a production deployment would.
|
||||
fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> {
|
||||
|
|
@ -330,7 +372,7 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> {
|
|||
"js" => "text/javascript",
|
||||
"wasm" => "application/wasm",
|
||||
"svg" => "image/svg+xml",
|
||||
"json" => "application/json",
|
||||
"json" | "webmanifest" => "application/json",
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"ecproj" => "application/json",
|
||||
|
|
|
|||
Loading…
Reference in New Issue