mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-03 06:52:21 -06:00
Merge upstream main into craft-ui-docking
craft-libs crates move to one rev, 37c907d2 (contains main's craft-update and craft-docpath pins). Docs: native floating windows are Windows, macOS and X11 only; the UI estimate drops the multi-monitor panel work that is done.
This commit is contained in:
@@ -19,7 +19,7 @@ const enc = (k) => encodeURIComponent(k);
|
||||
const dec = (n) => decodeURIComponent(n);
|
||||
|
||||
async function opfs() {
|
||||
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no OPFS");
|
||||
if (!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.
|
||||
@@ -116,7 +116,7 @@ export async function storeOpen(prefer) {
|
||||
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 {}
|
||||
try { if (navigator.storage?.persist) await navigator.storage.persist(); } catch {}
|
||||
return backend.name;
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export async function storeDelete(key) { return backend.del(key); }
|
||||
/// Ask the browser to keep the origin's storage under pressure; resolves with whether it is.
|
||||
export async function storePersist() {
|
||||
try {
|
||||
if (navigator.storage && navigator.storage.persist) return await navigator.storage.persist();
|
||||
if (navigator.storage?.persist) return await navigator.storage.persist();
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
@@ -227,7 +227,7 @@ export async function audioStart(workletUrl) {
|
||||
}
|
||||
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; };
|
||||
node.port.onmessage = (e) => { if (e.data?.underrun) underruns += e.data.underrun; };
|
||||
kind = "worklet";
|
||||
} catch (e) {
|
||||
console.warn("audio: AudioWorklet unavailable, using ScriptProcessorNode", e);
|
||||
@@ -318,11 +318,11 @@ function spawn(base) {
|
||||
const w = { worker, files: new Map(), ready: null, gpu: null };
|
||||
w.ready = new Promise((res, rej) => {
|
||||
worker.onmessage = (e) => {
|
||||
if (e.data && e.data.type === "gpu") {
|
||||
if (e.data?.type === "gpu") {
|
||||
w.gpu = e.data.gpu ? { adapter: e.data.gpu } : { cpu: e.data.error || "no GPU" };
|
||||
jobGpu = w.gpu;
|
||||
}
|
||||
if (e.data && e.data.type === "ready") res();
|
||||
if (e.data?.type === "ready") res();
|
||||
};
|
||||
worker.onerror = (e) => rej(new Error(e.message || "worker failed to start"));
|
||||
});
|
||||
@@ -341,7 +341,7 @@ export function workerRun(id, json, files, base, onMessage) {
|
||||
} catch (e) {
|
||||
// `new Worker` throws at once when the page's origin is opaque (a sandboxed iframe): the job
|
||||
// fails like one whose worker didn't start.
|
||||
setTimeout(() => onMessage("error", String((e && e.message) || e), null, null));
|
||||
setTimeout(() => onMessage("error", String(e?.message || e), null, null));
|
||||
return;
|
||||
}
|
||||
running.set(id, w);
|
||||
@@ -416,12 +416,12 @@ export function frameWorkerStart(base, onMessage, gpu) {
|
||||
// slot stays, and the failure is reported like a worker that died, after the caller has
|
||||
// recorded the index: frames then render on the page's thread (as with `?noworkers`).
|
||||
frameWorkers.push(null);
|
||||
setTimeout(() => onMessage(index, undefined, undefined, String((e && e.message) || e || "frame worker failed")));
|
||||
setTimeout(() => onMessage(index, undefined, undefined, String(e?.message || e || "frame worker failed")));
|
||||
return index;
|
||||
}
|
||||
worker.onmessage = (e) => {
|
||||
const m = e.data;
|
||||
if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
|
||||
if (m?.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined);
|
||||
};
|
||||
worker.onerror = (e) => {
|
||||
onMessage(index, undefined, undefined, String(e.message || "frame worker failed"));
|
||||
@@ -447,7 +447,7 @@ const cacheDirHandles = {};
|
||||
|
||||
async function cacheDir(kind = "frames") {
|
||||
if (cacheDirHandles[kind]) return cacheDirHandles[kind];
|
||||
if (!navigator.storage || !navigator.storage.getDirectory) throw new Error("no Origin Private File System");
|
||||
if (!navigator.storage?.getDirectory) throw new Error("no Origin Private File System");
|
||||
let d = await navigator.storage.getDirectory();
|
||||
for (const n of [CACHE_ROOT, "v1", kind]) d = await d.getDirectoryHandle(n, { create: true });
|
||||
cacheDirHandles[kind] = d;
|
||||
@@ -544,7 +544,7 @@ export async function cacheClear() {
|
||||
|
||||
/// Whether this thread has WebGPU (`navigator.gpu`).
|
||||
export function hasWebGpu() {
|
||||
return !!(self.navigator && self.navigator.gpu);
|
||||
return !!self.navigator?.gpu;
|
||||
}
|
||||
|
||||
/// Post a message ({type: "frame", json} or {type: "file", path, bytes}) to frame worker `index`.
|
||||
@@ -652,7 +652,7 @@ export async function browseForget(name) {
|
||||
export async function browseRead(path) {
|
||||
const rel = path.replace(/^\/Folders\//, "");
|
||||
const folder = folders.get(rel.split("/")[0]);
|
||||
const h = folder && folder.files.get(rel);
|
||||
const h = folder?.files.get(rel);
|
||||
if (!h) throw new Error(`${path}: its folder is not open (reconnect it)`);
|
||||
return new Uint8Array(await (await h.getFile()).arrayBuffer());
|
||||
}
|
||||
@@ -706,7 +706,7 @@ export function showDeviceLost(message, kept) {
|
||||
self.effectcraft
|
||||
.execute("file.save", {})
|
||||
.catch(() => self.effectcraft.execute("file.saveAs", { path: "/Untitled Project.ecproj" }))
|
||||
.catch((e) => (text.textContent = `Saving failed: ${(e && e.message) || e}`));
|
||||
.catch((e) => (text.textContent = `Saving failed: ${e?.message || e}`));
|
||||
const reload = document.createElement("button");
|
||||
reload.textContent = "Reload";
|
||||
reload.onclick = async () => {
|
||||
@@ -714,7 +714,7 @@ export function showDeviceLost(message, kept) {
|
||||
try {
|
||||
await self.effectcraft.flush();
|
||||
} catch (e) {
|
||||
text.textContent = `Writing to browser storage failed (${(e && e.message) || e}). Save the project, or reload anyway and lose the changes.`;
|
||||
text.textContent = `Writing to browser storage failed (${e?.message || e}). Save the project, or reload anyway and lose the changes.`;
|
||||
reload.textContent = "Reload Anyway";
|
||||
reload.disabled = false;
|
||||
reload.onclick = () => location.reload();
|
||||
|
||||
@@ -52,7 +52,7 @@ struct State {
|
||||
static STATE: LazyLock<Mutex<State>> = LazyLock::new(Mutex::default);
|
||||
|
||||
fn state() -> std::sync::MutexGuard<'static, State> {
|
||||
STATE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The web app's Media Browser.
|
||||
|
||||
@@ -48,14 +48,14 @@ pub fn list() -> Vec<(String, usize)> {
|
||||
|
||||
/// 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());
|
||||
QUIET.lock().unwrap_or_else(std::sync::PoisonError::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());
|
||||
OUTPUTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
|
||||
}
|
||||
|
||||
/// The table path for a picked/dropped file name.
|
||||
@@ -120,7 +120,7 @@ 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 mut q = QUIET.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let n = q.len();
|
||||
q.retain(|p| p != path);
|
||||
q.len() != n
|
||||
@@ -184,7 +184,7 @@ pub fn export_sink() -> Arc<effectcraft_export::Sink> {
|
||||
|
||||
/// Download the render outputs written since the last call.
|
||||
pub fn flush_downloads() {
|
||||
let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(|e| e.into_inner()));
|
||||
let paths = std::mem::take(&mut *OUTPUTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
let mut out: Vec<(String, Arc<[u8]>)> = paths.into_iter().filter_map(|p| get(&p).map(|d| (file_name(&p).to_string(), d))).collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out.dedup_by(|a, b| a.0 == b.0);
|
||||
|
||||
@@ -181,7 +181,7 @@ impl Default for Store {
|
||||
|
||||
impl Store {
|
||||
pub fn lock(&self) -> std::sync::MutexGuard<'_, Mirror> {
|
||||
self.mirror.lock().unwrap_or_else(|e| e.into_inner())
|
||||
self.mirror.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn now(&self) -> f64 {
|
||||
|
||||
@@ -162,7 +162,7 @@ try {
|
||||
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?.compositor, "GPU compositor on WebGPU");
|
||||
check(info.gpu.viewerOnGpu, "viewer frames on the GPU");
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ try {
|
||||
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)}`);
|
||||
check(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");
|
||||
@@ -378,14 +378,14 @@ try {
|
||||
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.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(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)}`);
|
||||
check(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(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 {
|
||||
@@ -418,7 +418,7 @@ try {
|
||||
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)}`);
|
||||
check(!mbImport.pending && 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"})`);
|
||||
@@ -579,7 +579,7 @@ try {
|
||||
report.steps.final2 = { screenshot: await shot("03-offline") };
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
report.error = String(e && e.stack || e);
|
||||
report.error = String(e?.stack || e);
|
||||
try { report.errorShot = await shot("error"); } catch {}
|
||||
}
|
||||
report.console = logs.slice(-40);
|
||||
|
||||
@@ -50,7 +50,7 @@ fn app_icon() -> egui::IconData {
|
||||
fn is_project_file(path: &str) -> bool {
|
||||
std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user