Files
Brandon ThomasandClaude Opus 5.5 a006ed5765 M13.32: web main-thread stall: Warp Stabilizer plans solved in job workers
warp.status solved the stabilization plan (Subspace Warp eigen-decompositions and mesh fits)
on the page the first time it saw an analysis: ~4.9 s blocking the browser's event loop in
the smoke test. The analysis job now sends the plan summary with its result, and a settings
change has a WarpPlan job solve it (status reports stabilizing meanwhile). Desktop unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 03:40:46 -04:00

579 lines
35 KiB
JavaScript

// Browser smoke test of the EffectCraft web app over the Chrome DevTools Protocol.
// No npm dependencies (Node ≥ 22: global WebSocket and fetch).
//
// 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), 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; M13.24: GPU frame workers, the storage manager and
// the disk cache; M13.30: GPU job workers (Render Queue frames and an analysis's input frames on
// the worker's WebGPU device, in passes) and layer buffers in the disk cache. 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";
import { tmpdir } from "node:os";
const arg = (k, d) => {
const i = process.argv.indexOf(`--${k}`);
return i > 0 ? process.argv[i + 1] : d;
};
const url = arg("url", "http://127.0.0.1:8765/");
const out = arg("out", join(tmpdir(), "effectcraft-web-smoke"));
const chrome = arg("chrome", process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "google-chrome");
const port = Number(arg("port", "9334"));
const headless = !process.argv.includes("--headed");
mkdirSync(out, { recursive: true });
const downloads = join(out, "downloads");
mkdirSync(downloads, { recursive: true });
const profile = mkdtempSync(join(tmpdir(), "ec-chrome-"));
const proc = spawn(chrome, [
...(headless ? ["--headless=new"] : []),
`--remote-debugging-port=${port}`,
`--user-data-dir=${profile}`,
"--no-first-run",
"--no-default-browser-check",
"--enable-unsafe-webgpu",
"--window-size=1600,1000",
"about:blank",
], { stdio: "ignore" });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ws;
let nextId = 1;
const waiting = new Map();
const logs = [];
async function connect() {
for (let i = 0; i < 100; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
const page = list.find((t) => t.type === "page");
if (page) {
ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && waiting.has(msg.id)) {
waiting.get(msg.id)(msg);
waiting.delete(msg.id);
} else if (msg.method === "Runtime.consoleAPICalled") {
logs.push(msg.params.args.map((a) => a.value ?? a.description ?? "").join(" "));
} else if (msg.method === "Runtime.exceptionThrown") {
logs.push("EXCEPTION " + JSON.stringify(msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text));
}
};
return;
}
} catch {}
await sleep(100);
}
throw new Error("Chrome did not start");
}
function send(method, params = {}) {
const id = nextId++;
ws.send(JSON.stringify({ id, method, params }));
return new Promise((res, rej) => waiting.set(id, (m) => (m.error ? rej(new Error(`${method}: ${m.error.message}`)) : res(m.result))));
}
async function js(expr, timeout = 300000) {
// (promise rejections carry plain strings: wrap them so the message survives)
const wrapped = `(async () => { try { return await (${expr}); } catch (e) { throw new Error(String(e && e.message || e)); } })()`;
const r = await send("Runtime.evaluate", { expression: wrapped, awaitPromise: true, returnByValue: true, timeout });
if (r.exceptionDetails) throw new Error(`${expr}: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`);
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`);
writeFileSync(p, Buffer.from(r.data, "base64"));
return p;
}
async function until(expr, ms = 60000, step = 200) {
const t0 = Date.now();
while (Date.now() - t0 < ms) {
if (await js(expr)) return Date.now() - t0;
await sleep(step);
}
throw new Error(`timeout waiting for ${expr}`);
}
async function waitDownload(pred, ms = 120000) {
const t0 = Date.now();
while (Date.now() - t0 < ms) {
const f = readdirSync(downloads).find((n) => pred(n) && !n.endsWith(".crdownload"));
if (f) return { name: f, bytes: statSync(join(downloads, f)).size };
await sleep(200);
}
throw new Error("no download");
}
const report = { url, steps: {} };
let failed = false;
try {
await connect();
await send("Page.enable");
await send("Runtime.enable");
await send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: downloads }).catch(() => {});
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
const t0 = Date.now();
await send("Page.navigate", { url });
report.load = await ready();
report.load.wallMs = Date.now() - t0;
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);
await sleep(1500);
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;
// Viewer frames render in frame workers (project replicas fed by diffs): with the CPU
// renderer, scrubbing (and an edit, which travels as a diff) keeps the page's event loop free.
await js(`effectcraft.execute("render.backend", {backend: "cpu"})`);
report.steps.frameWorkers = await js(`(async () => {
const before = effectcraft.info().frameWorkers;
let maxGapMs = 0, last = performance.now();
const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10);
await effectcraft.execute("layer.newSolid", {name: "Frame Worker Solid", color: "#3366cc", width: 120, height: 80});
for (let k = 0; k < 12; k++) {
await effectcraft.execute("time.set", {time: k / 4});
await new Promise((r) => setTimeout(r, 150));
}
const t0 = performance.now();
while (effectcraft.info().frameWorkers.rendered < before.rendered + 3 && performance.now() - t0 < 120000) await new Promise((r) => setTimeout(r, 100));
clearInterval(timer);
await effectcraft.execute("edit.undo", {});
return {before, after: effectcraft.info().frameWorkers, maxGapMs};
})()`);
const fwk = report.steps.frameWorkers;
await js(`effectcraft.execute("render.backend", {backend: "gpu"})`);
check(fwk.after.alive > 0, `frame workers alive: ${JSON.stringify(fwk.after)}`);
check(fwk.after.rendered >= fwk.before.rendered + 3, `frames rendered in workers: ${JSON.stringify(fwk)}`);
check(fwk.after.syncs >= 2, `project synced as diffs: ${JSON.stringify(fwk.after)}`);
check(fwk.maxGapMs < 400, `event loop blocked ${fwk.maxGapMs} ms while scrubbing`);
// GPU effects in the browser (M13.24): each frame worker opens its own WebGPU device; frames
// with effects go to the workers, which render them on the GPU in passes (awaiting the
// device's readbacks), while the page's event loop stays free.
const gw = await js(`(async () => {
const t0 = performance.now();
while (effectcraft.info().frameWorkers.gpu.some((g) => g === null) && performance.now() - t0 < 30000) await new Promise((r) => setTimeout(r, 200));
return {gpu: effectcraft.info().frameWorkers.gpu, workerWebGpu: effectcraft.info().webgpu};
})()`);
report.steps.gpuWorkers = gw;
if (gw.gpu.some((g) => typeof g === "string")) {
report.steps.gpuWorkers.effects = await js(`(async () => {
const before = effectcraft.info().frameWorkers;
let maxGapMs = 0, last = performance.now();
const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10);
await effectcraft.execute("layer.newSolid", {name: "GPU Blur Solid", color: "#cc3366", width: 300, height: 200});
await effectcraft.execute("effect.apply", {effect: "Gaussian Blur"});
await effectcraft.execute("prop.set", {path: "effects/#1/blurriness", value: 25});
await effectcraft.execute("effect.apply", {effect: "Glow"});
for (let k = 0; k < 6; k++) {
await effectcraft.execute("time.set", {time: 0.3 + k / 5});
await new Promise((r) => setTimeout(r, 200));
}
const t0 = performance.now();
while (effectcraft.info().frameWorkers.gpuFrames < before.gpuFrames + 3 && performance.now() - t0 < 120000) await new Promise((r) => setTimeout(r, 100));
clearInterval(timer);
const after = effectcraft.info().frameWorkers;
// The worker's GPU frame against the page's CPU render of the same frame.
let parity = null;
for (let i = 0; i < 100 && !parity; i++) {
parity = await effectcraft.workerFrameCheck({scale: 0.5}).catch(() => null);
if (!parity) await new Promise((r) => setTimeout(r, 100));
}
after.parity = parity;
await effectcraft.execute("edit.undo", {});
await effectcraft.execute("edit.undo", {});
await effectcraft.execute("edit.undo", {});
await effectcraft.execute("edit.undo", {});
return {before, after, maxGapMs};
})()`);
const ge = report.steps.gpuWorkers.effects;
report.steps.gpuWorkers.screenshot = await shot("01b-gpu-effects");
check(ge.after.gpuFrames >= ge.before.gpuFrames + 3, `GPU effect frames rendered in workers: ${JSON.stringify(ge)}`);
check(ge.after.lastPasses >= 2, `frames rendered in passes (deferred readbacks): ${ge.after.lastPasses}`);
const pa = ge.after.parity;
check(pa && pa.meanDiff < 0.5 && pa.over4 < 0.005, `GPU worker frame vs the CPU: ${JSON.stringify(pa)}`);
check(ge.maxGapMs < 400, `event loop blocked ${ge.maxGapMs} ms while GPU workers rendered`);
} else {
report.steps.gpuWorkers.note = "no WebGPU in workers here: frame workers render on their CPU";
}
// The storage manager (Settings ▸ Disk ▸ Browser Storage) and its commands.
await js(`effectcraft.execute("storage.persist", {})`);
const st = await js(`(async () => { await new Promise((r) => setTimeout(r, 1200)); return effectcraft.execute("storage.info", {}); })()`);
report.steps.storage = st;
check(st.available && st.quota > 0 && st.usage > 0, `storage.info: ${JSON.stringify(st)}`);
check(st.diskCache && st.diskCache.enabled && st.diskCache.writes > 0, `disk cache in OPFS: ${JSON.stringify(st.diskCache)}`);
await js(`effectcraft.execute("prefs.open", {page: "disk"})`);
await sleep(800);
report.steps.storage.screenshot = await shot("01c-storage-manager");
const ids = await js(`effectcraft.request("ui.elements", {prefix: "settings.storage."}).then((l) => l.map((e) => e.id))`);
report.steps.storage.elements = ids;
check(ids.includes("settings.storage.usage") && ids.includes("settings.storage.clear.diskCache"), `storage manager widgets: ${ids}`);
await js(`effectcraft.request("ui.click", {id: "settings.cancel"})`);
await sleep(300);
// Render Queue: a short, small GIF → download. `renderQueue.render` waits by default; the
// render still runs in a worker and the reply comes when it ends (the page keeps running).
const rqT = Date.now();
// (`ui.menu.invoke`: `engine.execute`'s strict parameter check does not know the spread
// Render Settings / Output Module keys)
await js(`effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", output: "smoke_[compName].gif", resolution: 0.25, timeSpan: "custom", start: 0, end: 1}})`);
const waited = await js(`(async () => {
let maxGapMs = 0, last = performance.now();
const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10);
const r = await effectcraft.execute("renderQueue.render", {});
clearInterval(timer);
return {rendering: r.rendering, status: r.items.map((i) => i.status), maxGapMs};
})()`);
const gif = await waitDownload((n) => n.endsWith(".gif"));
report.steps.gifExport = { ...gif, ms: Date.now() - rqT, waited };
check(!waited.rendering && waited.status.every((s) => s === "Done"), `wait:true replies when done: ${JSON.stringify(waited)}`);
check(waited.maxGapMs < 400, `event loop blocked ${waited.maxGapMs} ms during a waiting render`);
await idle();
// An image sequence arrives as one .zip.
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");
// GPU in the job workers (M13.30): a Render Queue job and a Warp Stabilizer analysis whose
// frames have GPU effects render on the job worker's own WebGPU device, in passes (the job
// awaits the device's readbacks between them), while the page's event loop stays free.
if (report.steps.gpuWorkers.effects) {
await js(`effectcraft.execute("renderQueue.setRender", {index: 3, render: false})`);
report.steps.gpuJobs = await js(`(async () => {
let maxGapMs = 0, last = performance.now();
const timer = setInterval(() => { const now = performance.now(); maxGapMs = Math.max(maxGapMs, now - last); last = now; }, 10);
const gaps = {};
// (a phase ends once the timer has run: a stall at its end counts in it, not the next)
const phase = async (n) => { await new Promise((res) => setTimeout(res, 30)); gaps[n] = maxGapMs; maxGapMs = 0; };
await effectcraft.execute("layer.newSolid", {name: "GPU Job Solid", color: "#3366cc", width: 320, height: 180});
await effectcraft.execute("effect.apply", {effect: "Fractal Noise"});
await effectcraft.execute("prop.addKey", {path: "effects/#1/transform/offset", time: 0, value: [0.5, 0.5]});
await effectcraft.execute("prop.addKey", {path: "effects/#1/transform/offset", time: 1, value: [0.65, 0.58]});
await effectcraft.execute("effect.apply", {effect: "Gaussian Blur"});
await effectcraft.execute("prop.set", {path: "effects/#2/blurriness", value: 12});
// (A resolution no earlier render used: Backend Auto warms this comp and scale up afresh,
// GPU first.)
await effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "png", output: "gpujob_[####].png", resolution: 0.2, timeSpan: "custom", start: 0, end: 0.4}});
const t0 = performance.now();
const r = await effectcraft.execute("renderQueue.render", {});
const renderMs = performance.now() - t0;
await phase("render");
// Applying Warp Stabilizer starts its analysis in a job worker (as in After Effects).
await effectcraft.execute("effect.apply", {effect: "Warp Stabilizer"});
const t1 = performance.now();
let warp;
do {
await new Promise((res) => setTimeout(res, 200));
warp = await effectcraft.execute("warp.status", {});
} while (!warp.analyzed && performance.now() - t1 < 180000);
const warpMs = performance.now() - t1;
await phase("warp");
// Content-Aware Fill (a job worker too): its input is the layer's source, so the noise and
// blur go into a precomp (rendered on the worker's GPU); cut a hole, fill half a second.
// Editing on the page after the analysis (M13.32: the first warp.status with the
// analysis used to solve the stabilization plan on the page, ≈ 5 s; now the job sends
// it): every command stays a few UI frames.
await effectcraft.execute("effect.remove", {effect: "Warp Stabilizer"});
await effectcraft.execute("layer.precompose", {name: "GPU Fill Source", mode: "move", open: false});
await effectcraft.execute("layer.addMask", {rect: [140, 70, 40, 40], mode: "subtract"});
const wa = await effectcraft.execute("comp.info", {}).then((c) => c.workArea).catch(() => null);
await effectcraft.execute("comp.workArea", {start: 0, end: 0.5});
await phase("prepareFill");
const t2 = performance.now();
const fill = await effectcraft.execute("contentFill.generate", {method: "edgeBlend", wait: true}).catch((e) => ({error: String(e)}));
const fillMs = performance.now() - t2;
const fillLayers = await effectcraft.execute("comp.info", {}).then((c) => c.layers.map((l) => l.name).filter((n) => n.startsWith("Fill ")));
await phase("fill");
clearInterval(timer);
maxGapMs = Math.max(gaps.render, gaps.warp, gaps.fill);
// Back to the project as it was.
const c = await effectcraft.execute("comp.info", {});
const ids = c.layers.filter((l) => l.name === "GPU Fill Source" || l.name.startsWith("Fill ")).map((l) => l.id);
await effectcraft.execute("edit.clear", {layers: ids});
if (wa) await effectcraft.execute("comp.workArea", {start: wa[0], end: wa[1]});
const workers = effectcraft.info().workers;
return {status: r.items.map((i) => i.status), renderMs, warp, warpMs, fill, fillMs, fillLayers, workArea: wa, workers, maxGapMs, gaps};
})()`);
const gj = report.steps.gpuJobs;
gj.download = await waitDownload((n) => n.startsWith("gpujob"));
const jobs = gj.workers.jobs || [];
const rj = jobs.filter((j) => j.kind === "Render").pop();
const wj = jobs.filter((j) => j.kind === "Warp").pop();
check(gj.workers.gpu && gj.workers.gpu.adapter, `job workers have a GPU: ${JSON.stringify(gj.workers)}`);
check(gj.status.every((st) => st === "Done"), `GPU job render: ${JSON.stringify(gj.status)}`);
check(rj && rj.gpu && rj.readbacks >= 3 && rj.passes >= 6, `Render Queue frames on the job worker's GPU, in passes: ${JSON.stringify(rj)}`);
check(gj.warp.analyzed, `Warp Stabilizer analysed in a job worker: ${JSON.stringify(gj.warp)}`);
check(wj && wj.gpu && wj.readbacks > 0, `Warp Stabilizer input frames on the job worker's GPU: ${JSON.stringify(wj)}`);
const fj = jobs.filter((j) => j.kind === "ContentFill").pop();
check(!gj.fill.error && gj.fillLayers.length > 0, `Content-Aware Fill in a job worker: ${JSON.stringify(gj.fill)} ${JSON.stringify(gj.fillLayers)}`);
check(fj && fj.gpu && fj.readbacks > 0, `Content-Aware Fill frames on the job worker's GPU: ${JSON.stringify(fj)}`);
check(gj.maxGapMs < 400, `event loop blocked ${gj.maxGapMs} ms during the GPU jobs: ${JSON.stringify(gj.gaps)}`);
check(gj.gaps.prepareFill < 50, `event loop blocked ${gj.gaps.prepareFill} ms while editing after the analysis (pre-compose, mask): ${JSON.stringify(gj.gaps)}`);
} else {
report.steps.gpuJobs = { note: "no WebGPU in workers here: job workers render on their CPU" };
}
// 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);
// Media Browser: browser storage (where tone.wav now is) and its actions.
const mb = await js(`effectcraft.execute("mediaBrowser.go", {})`);
report.steps.mediaBrowser = { path: mb.path, entries: mb.entries.map((e) => e.name), places: mb.places, actions: mb.actions.map((a) => a.id) };
check(mb.path === "/Browser Storage" && mb.entries.some((e) => e.name === "tone.wav" && e.kind === "audio"), `Media Browser: ${JSON.stringify(report.steps.mediaBrowser)}`);
check(mb.actions.some((a) => a.id === "addFiles"), "Media Browser: Add Files…");
const mbImport = await js(`effectcraft.execute("mediaBrowser.import", {paths: [${JSON.stringify(mb.entries.find((e) => e.name === "tone.wav")?.path ?? "/tone.wav")}]})`);
check(!mbImport.pending && mbImport.items && mbImport.items.length === 1, `Media Browser import: ${JSON.stringify(mbImport)}`);
await js(`effectcraft.request("ui.playback", {action: "stop"})`);
await js(`effectcraft.execute("time.set", {time: 0})`);
await js(`effectcraft.request("ui.playback", {action: "play"})`);
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"})`);
const proj = await waitDownload((n) => n.endsWith(".ecproj"));
report.steps.save = proj;
await js(`effectcraft.execute("file.newProject", {})`);
const opened = await js(`(async () => { const d = effectcraft.readFile("/smoke.ecproj"); const p = await effectcraft.addFile(new File([d], "smoke-copy.ecproj")); await new Promise(r => setTimeout(r, 800)); return {path: p, files: effectcraft.files()}; })()`);
const comps = await js(`effectcraft.inspect().then(i => i.activeComp)`);
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"})`);
// A layer slow enough on the CPU for its buffer to go to the disk cache too (M13.30).
await js(`(async () => {
await effectcraft.execute("effect.apply", {effect: "Fractal Noise"});
await effectcraft.execute("prop.set", {path: "effects/#1/complexity", value: 10});
await effectcraft.execute("effect.apply", {effect: "Gaussian Blur"});
await effectcraft.execute("prop.set", {path: "effects/#2/blurriness", value: 60});
})()`);
// Disk cache: frames the workers render now are stored in the Origin Private File System
// (the CPU renderer sends every frame to the workers).
await js(`effectcraft.execute("render.backend", {backend: "cpu"})`);
const diskTimes = [0.4, 0.8, 1.2];
const stored = await js(`(async () => {
const w0 = (await effectcraft.execute("cache.diskStats", {})).writes;
for (const t of ${JSON.stringify(diskTimes)}) {
await effectcraft.execute("time.set", {time: t});
await new Promise((r) => setTimeout(r, 300));
}
const t0 = performance.now();
let st;
do {
await new Promise((r) => setTimeout(r, 200));
st = await effectcraft.execute("cache.diskStats", {});
} while (st.writes < w0 + ${diskTimes.length} && performance.now() - t0 < 60000);
return {writesBefore: w0, after: st};
})()`);
check(stored.after.writes >= stored.writesBefore + diskTimes.length, `frames written to the disk cache: ${JSON.stringify(stored)}`);
check(stored.after.layers > 0 && stored.after.layerWrites > 0, `layer buffers written to the disk cache: ${JSON.stringify(stored.after)}`);
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}`);
// The disk cache survived the reload: the same frames come from it, not from a render.
report.steps.diskCache = await js(`(async () => {
const t0 = performance.now();
let st;
do {
await new Promise((r) => setTimeout(r, 200));
st = await effectcraft.execute("cache.diskStats", {});
} while (!st.loaded && performance.now() - t0 < 20000);
const listed = st.entries;
for (const t of ${JSON.stringify(diskTimes)}) {
await effectcraft.execute("time.set", {time: t});
await new Promise((r) => setTimeout(r, 300));
}
const t1 = performance.now();
while (effectcraft.info().frameWorkers.diskHits < ${diskTimes.length} && performance.now() - t1 < 30000) await new Promise((r) => setTimeout(r, 200));
return {listed, stats: await effectcraft.execute("cache.diskStats", {}), frameWorkers: effectcraft.info().frameWorkers};
})()`);
const dcr = report.steps.diskCache;
dcr.stored = stored;
// Layer buffers from the disk cache (M13.30): after a purge (the workers' memory is empty), a
// frame not seen yet renders and reports its layer misses; the page then names the ones on
// disk, and the worker of the next frame reads them before rendering: its lookups hit.
dcr.layers = await js(`(async () => {
const before = await effectcraft.execute("cache.diskStats", {});
const hits = [];
for (const t of [2.05, 2.15, 2.25]) {
await effectcraft.execute("edit.purge", {what: "memory"});
await effectcraft.execute("time.set", {time: t});
const r0 = effectcraft.info().frameWorkers.rendered, t0 = performance.now();
while (effectcraft.info().frameWorkers.rendered <= r0 && performance.now() - t0 < 30000) await new Promise((r) => setTimeout(r, 100));
hits.push((await effectcraft.execute("cache.diskStats", {})).layerHits);
}
return {before, after: await effectcraft.execute("cache.diskStats", {}), hits};
})()`);
check(dcr.layers.before.layers > 0, `layer entries after reload: ${JSON.stringify(dcr.layers.before)}`);
check(dcr.layers.after.layerHits > dcr.layers.before.layerHits, `layer buffers served from the disk cache: ${JSON.stringify(dcr.layers)}`);
check(dcr.listed >= diskTimes.length, `disk cache entries after reload: ${JSON.stringify(dcr)}`);
check(dcr.frameWorkers.diskHits >= diskTimes.length, `cache hits after reload: ${JSON.stringify(dcr.frameWorkers)}`);
const cleared = await js(`effectcraft.execute("storage.clear", {what: "diskCache"})`);
const afterClear = await js(`effectcraft.execute("cache.diskStats", {})`);
dcr.cleared = { cleared, afterClear };
check(cleared.entries >= diskTimes.length && afterClear.entries === 0, `storage.clear diskCache: ${JSON.stringify(dcr.cleared)}`);
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);
try { report.errorShot = await shot("error"); } catch {}
}
report.console = logs.slice(-40);
writeFileSync(join(out, "report.json"), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report, null, 2));
try { ws.close(); } catch {}
proc.kill();
process.exit(failed ? 1 : 0);