mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 16:33:01 -06:00
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:
co-authored by
Claude Opus 5.5
parent
c406b71cba
commit
0bee1ab2da
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user