diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 702c5d33..793900be 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -615,22 +615,72 @@ export async function browseRead(path) { } /// The fallback where folders can't be opened: pick files (or a whole folder, `directory`) with a -/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder). +/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder). A file +/// that can't be read is listed as {path, error} instead (#226); cancelling resolves with []. export function browsePickFiles(directory) { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { const input = document.createElement("input"); input.type = "file"; input.multiple = true; if (directory) input.webkitdirectory = true; + input.oncancel = () => resolve([]); input.onchange = async () => { - try { - const out = []; - for (const f of input.files) { - out.push({ path: (directory && f.webkitRelativePath) || f.name, bytes: new Uint8Array(await f.arrayBuffer()) }); + const out = []; + for (const f of input.files) { + const path = (directory && f.webkitRelativePath) || f.name; + try { + out.push({ path, bytes: new Uint8Array(await f.arrayBuffer()) }); + } catch (e) { + out.push({ path, error: String((e && (e.message || e.name)) || e) }); } - resolve(out); - } catch (e) { reject(e); } + } + resolve(out); }; input.click(); }); } + +/// The page's WebGPU device was lost (#225): the canvas can't show anything new while the project +/// stays open and editable. Say so over the stale picture, and offer to save the project (a +/// download) and to reload; the reload first writes pending changes to browser storage, from +/// which the session comes back when it is `kept` there. +export function showDeviceLost(message, kept) { + if (document.getElementById("device-lost")) return; + const box = document.createElement("div"); + box.id = "device-lost"; + box.setAttribute("role", "alertdialog"); + const title = document.createElement("strong"); + title.textContent = "The graphics device was lost"; + const text = document.createElement("p"); + text.textContent = + "EffectCraft can't update the display any more. Your project is still open. " + + (kept ? "Reload to continue: the project and its unsaved changes come back." : "This session isn't kept in browser storage: save the project before you reload."); + const detail = document.createElement("small"); + detail.textContent = message; + const save = document.createElement("button"); + save.textContent = "Save Project"; + save.onclick = () => + 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}`)); + const reload = document.createElement("button"); + reload.textContent = "Reload"; + reload.onclick = async () => { + reload.disabled = true; + 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.`; + reload.textContent = "Reload Anyway"; + reload.disabled = false; + reload.onclick = () => location.reload(); + return; + } + location.reload(); + }; + const buttons = document.createElement("div"); + buttons.append(save, reload); + box.append(title, text, detail, buttons); + document.body.append(box); +} diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index ded2c56c..48f296c9 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -11,6 +11,12 @@ use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; use wasm_bindgen::prelude::*; +#[wasm_bindgen(module = "/js/host.js")] +extern "C" { + #[wasm_bindgen(js_name = showDeviceLost)] + fn show_device_lost(message: &str, kept: bool); +} + /// Work for the UI thread with access to the app (posted by async tasks: picked files…). type Action = Box; @@ -215,9 +221,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { log::error!("{message}"); } })); + // The canvas can't present any more (#225): the page says so over it and + // offers a reload, which brings the session back when browser storage keeps it. let lost = gpu_failures.clone(); + let kept = !empty && persist::backend() != "memory"; rs.device.set_device_lost_callback(move |reason, message| { - lost.report(&format!("GPU device lost ({reason:?}): {message}"), true); + let message = format!("GPU device lost ({reason:?}): {message}"); + lost.report(&message, true); + show_device_lost(&message, kept); }); } let mut session = session(); diff --git a/apps/effectcraft-web/src/browse.rs b/apps/effectcraft-web/src/browse.rs index aba2eb22..ad7500fe 100644 --- a/apps/effectcraft-web/src/browse.rs +++ b/apps/effectcraft-web/src/browse.rs @@ -105,7 +105,7 @@ fn spawn(p: js_sys::Promise, what: &'static str, then: impl FnOnce(&mut effectcr crate::post(move |app| then(app, v)); } Err(e) => { - let msg = e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_default(); + let msg = crate::persist::js_err(e); // Cancelling a picker is not an error. if !msg.contains("abort") && !msg.contains("AbortError") { crate::post(move |app| app.ui.status = format!("{what}: {msg}")); @@ -234,20 +234,42 @@ impl Browser for WebBrowser { Ok(json!({"pending": true})) } "addFiles" | "uploadFolder" => { + let what = if id == "uploadFolder" { "Upload Folder…" } else { "Add Files…" }; let p = pick_files(id == "uploadFolder"); wasm_bindgen_futures::spawn_local(async move { - let Ok(list) = wasm_bindgen_futures::JsFuture::from(p).await else { return }; - let mut n = 0; - for f in js_sys::Array::from(&list).iter() { - let path = js_sys::Reflect::get(&f, &"path".into()).ok().and_then(|v| v.as_string()); - let bytes = js_sys::Reflect::get(&f, &"bytes".into()).ok().and_then(|b| b.dyn_into::().ok()); - if let (Some(path), Some(bytes)) = (path, bytes) { - crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into()); - n += 1; + let list = match wasm_bindgen_futures::JsFuture::from(p).await { + Ok(list) => js_sys::Array::from(&list), + Err(e) => { + let msg = crate::persist::js_err(e); + crate::post(move |app| app.ui.status = format!("{what}: {msg}")); + return; } + }; + // Files that can't be read are reported, not dropped silently (#226). + let (mut n, mut unread) = (0, vec![]); + for f in list.iter() { + let get = |key: &str| js_sys::Reflect::get(&f, &key.into()).ok(); + let Some(path) = get("path").and_then(|v| v.as_string()) else { continue }; + match get("bytes").and_then(|b| b.dyn_into::().ok()) { + Some(bytes) => { + crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into()); + n += 1; + } + None => unread.push(match get("error").and_then(|e| e.as_string()).filter(|e| !e.is_empty()) { + Some(why) => format!("{path} ({why})"), + None => path, + }), + } + } + // Cancelled: nothing to report. + if n == 0 && unread.is_empty() { + return; } crate::post(move |app| { app.ui.status = format!("{n} file(s) added to Browser Storage"); + if !unread.is_empty() { + app.ui.status += &format!(". Could not read {}: try {what} again", unread.join(", ")); + } go(app, STORAGE); }); }); diff --git a/apps/effectcraft-web/src/persist.rs b/apps/effectcraft-web/src/persist.rs index 93fe8906..a2345cc3 100644 --- a/apps/effectcraft-web/src/persist.rs +++ b/apps/effectcraft-web/src/persist.rs @@ -60,7 +60,8 @@ pub fn last_error() -> Option { LAST_ERROR.with(|e| e.borrow().clone()) } -fn js_err(e: JsValue) -> String { +/// A JavaScript error (a rejected promise's reason) as text. +pub(crate) 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:?}")) } diff --git a/apps/effectcraft-web/tests/page.mjs b/apps/effectcraft-web/tests/page.mjs new file mode 100644 index 00000000..06301523 --- /dev/null +++ b/apps/effectcraft-web/tests/page.mjs @@ -0,0 +1,129 @@ +// Page-side helpers of the web app (js/host.js) under Node with a fake DOM, no browser or Wasm +// build needed: +// +// node apps/effectcraft-web/tests/page.mjs +// +// - Add Files… / Upload Folder…: a picked file that can't be read is listed with its error +// instead of failing the whole pick silently, and cancelling picks nothing (#226). +// - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload; +// the reload writes pending changes to browser storage first, and a failed write is reported +// instead of reloading (#225). +// Exits non-zero on failure. +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const tick = () => new Promise((r) => setTimeout(r, 0)); +globalThis.self = globalThis; + +class FakeElement { + constructor(tag) { + this.tag = tag; + this.children = []; + this.attributes = {}; + this.textContent = ""; + } + setAttribute(k, v) { + this.attributes[k] = v; + } + append(...c) { + this.children.push(...c); + } + click() { + this.onclick?.(); + } + *all() { + yield this; + for (const c of this.children) yield* c.all(); + } +} +let picker; +globalThis.document = { + body: new FakeElement("body"), + createElement(tag) { + const el = new FakeElement(tag); + if (tag === "input") { + picker = el; + el.click = () => {}; + } + return el; + }, + getElementById(id) { + return [...this.body.all()].find((e) => e.id === id) ?? null; + }, +}; +let reloads = 0; +globalThis.location = { reload: () => reloads++ }; + +const host = await import(pathToFileURL(join(here, "../js/host.js"))); + +// #226: one readable and one unreadable file. +const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read }); +const picked = host.browsePickFiles(false); +picker.files = [ + file("ok.png", async () => new Uint8Array([1, 2, 3]).buffer), + file("red-corner.png", async () => { + throw new DOMException("Generated unreadable selected file", "NotReadableError"); + }), +]; +await picker.onchange(); +const out = await picked; +assert.equal(out.length, 2); +assert.deepEqual([out[0].path, [...out[0].bytes]], ["ok.png", [1, 2, 3]]); +assert.deepEqual(out[1], { path: "red-corner.png", error: "Generated unreadable selected file" }); +const cancelled = host.browsePickFiles(false); +picker.oncancel(); +assert.deepEqual(await cancelled, [], "cancelling picks nothing"); + +// #225: the device-lost notice. +const calls = []; +let flushFails = false; +self.effectcraft = { + flush: async () => { + calls.push("flush"); + if (flushFails) throw new Error("quota exceeded"); + }, + execute: async (command, params) => { + calls.push([command, params]); + if (command === "file.save") throw new Error("no path: use file.saveAs {path}"); + }, +}; +const notice = () => document.getElementById("device-lost"); +const buttons = () => [...notice().all()].filter((e) => e.tag === "button"); +const text = () => [...notice().all()].find((e) => e.tag === "p").textContent; + +host.showDeviceLost("GPU device lost (Destroyed): destroyed", true); +host.showDeviceLost("again", true); +assert.equal(document.body.children.length, 1, "one notice"); +assert.equal([...notice().all()].find((e) => e.tag === "small").textContent, "GPU device lost (Destroyed): destroyed"); +assert.match(text(), /Reload to continue/); +assert.deepEqual( + buttons().map((b) => b.textContent), + ["Save Project", "Reload"], +); +// Save Project: Save, or Save As for an untitled project (a download). +await buttons()[0].onclick(); +assert.deepEqual(calls.splice(0), [ + ["file.save", {}], + ["file.saveAs", { path: "/Untitled Project.ecproj" }], +]); +// Reload writes pending changes first. +await buttons()[1].onclick(); +assert.deepEqual(calls.splice(0), ["flush"]); +assert.equal(reloads, 1); + +// A failed write is reported instead of reloading; Reload Anyway reloads. +document.body.children = []; +flushFails = true; +host.showDeviceLost("GPU device lost (Unknown): gone", false); +assert.match(text(), /isn't kept in browser storage/); +await buttons()[1].onclick(); +await tick(); +assert.equal(reloads, 1, "no reload after a failed write"); +assert.match(text(), /quota exceeded/); +assert.equal(buttons()[1].textContent, "Reload Anyway"); +buttons()[1].onclick(); +assert.equal(reloads, 2); + +console.log("page: ok"); diff --git a/apps/effectcraft-web/web/index.html b/apps/effectcraft-web/web/index.html index 578e6159..70ab93ff 100644 --- a/apps/effectcraft-web/web/index.html +++ b/apps/effectcraft-web/web/index.html @@ -20,6 +20,23 @@ } #loading img { width: 56px; height: 56px; } #loading .err { color: #ff8a7a; white-space: pre-wrap; max-width: 70ch; pointer-events: auto; } + #device-lost { + position: absolute; inset: 0; display: flex; flex-direction: column; gap: 10px; + align-items: center; justify-content: center; padding: 16px; text-align: center; + background: rgb(18 18 18 / 0.85); color: #c8c8c8; font: 14px/1.4 system-ui, sans-serif; + } + #device-lost strong { color: #e3e3e3; font-size: 16px; } + #device-lost p { margin: 0; max-width: 60ch; } + #device-lost small { color: #8c8c8c; font-size: 12px; } + #device-lost div { display: flex; gap: 8px; } + #device-lost button { + padding: 6px 14px; border: 1px solid #3a3a3a; border-radius: 4px; + background: #232323; color: #e3e3e3; font: inherit; cursor: pointer; + } + #device-lost button:hover { background: #303030; } + #device-lost button:last-child { background: #2d8ceb; border-color: #2d8ceb; } + #device-lost button:last-child:hover { background: #4aa0f5; } + #device-lost button:disabled { opacity: 0.6; cursor: default; } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 8d84e71d..c14b8131 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -174,6 +174,12 @@ fn fit(r: Rect, w: f32, h: f32) -> Rect { Rect::from_center_size(r.center(), vec2(w * s, h * s)) } +/// A scroll bar thumb's length on a `track` long: the share of `content` in view, at least 16 +/// points but never longer than the track (a panel only a few points tall still draws, #231). +fn thumb_len(track: f32, visible: f32, content: f32) -> f32 { + (track * visible / content).max(16.0).min(track).max(0.0) +} + /// An inline text edit in progress: (item, field `name`|`comment`, text). type Editing = (u64, String, String); fn edit_id() -> egui::Id { @@ -340,7 +346,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } if overflow > 0.0 { let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0)); - let tw = (track.width() * track.width() / (track.width() + overflow)).max(16.0); + let tw = thumb_len(track.width(), track.width(), track.width() + overflow); let thumb = Rect::from_min_size(pos2(track.min.x + (track.width() - tw) * (hscroll / overflow), track.min.y), vec2(tw, track.height())); let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("proj-hscroll"), Sense::drag()); p.rect_filled(track, 2.0, t.field_bg); @@ -396,7 +402,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let scroll = app.ui.project_scroll; if max_scroll > 0.0 { let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0)); - let th = (track.height() * list.height() / content_h).clamp(16.0, track.height()); + let th = thumb_len(track.height(), list.height(), content_h); let thumb = Rect::from_min_size(pos2(track.min.x, track.min.y + (track.height() - th) * (scroll / max_scroll)), vec2(track.width(), th)); let vresp = ui.interact(track.expand2(vec2(2.0, 0.0)), egui::Id::new("proj-vscroll"), Sense::drag()); p.rect_filled(thumb, 2.0, if vresp.hovered() || vresp.dragged() { t.text_dim } else { t.text_faint }); diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 074b41b3..1fc07668 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -119,3 +119,15 @@ fn dragging_in_the_empty_area_box_selects_items() { click_at(&mut h, from); assert!(h.state().session.state.project_selection.is_empty()); } + +/// A Project panel shorter than its scroll bar's thumb draws instead of panicking: an empty +/// project in a 1280 × 800 browser window at device pixel ratio 2 (640 × 400 points) left the +/// list too short for the thumb's 16-point minimum (#231). +#[test] +fn a_short_project_panel_draws() { + for height in [400.0, 300.0] { + let mut h = Harness::builder().with_size(egui::vec2(640.0, height)).build_eframe(|_| EffectcraftApp::new(Session::default())); + h.run_steps(3); + assert!(h.state().auto.find("project.empty").is_some(), "the Project panel drew at {height} points"); + } +} diff --git a/docs/web.md b/docs/web.md index f3b44306..8c198970 100644 --- a/docs/web.md +++ b/docs/web.md @@ -230,7 +230,8 @@ Window ▸ Media Browser browses a virtual tree (`src/browse.rs`): - **Browser Storage**: every file in browser storage (imported media, saved projects, files added with **Add Files…** or **Upload Folder…**, plain file inputs that work in every browser). This is - the persistent recent list where folders can't be opened. + the persistent recent list where folders can't be opened. A picked file that can't be read is + named in the status bar with the browser's reason, and the others are still added. - **Folders**: **Open Folder…** (File System Access API, `showDirectoryPicker`, Chromium) lists a folder from the user's disk; only the listing is read, and a file's bytes are read when it is imported (then kept in browser storage). The folder handles are remembered in IndexedDB; after @@ -262,6 +263,13 @@ eyedroppers read GPU frames back asynchronously (`Gpu::read_display_async`: the frame later). On WebGL2, or without a usable adapter, everything renders on the CPU. `effectcraft.info().gpu` reports `{compositor, viewerOnGpu}`. +If the page's WebGPU device is lost (a driver reset, the browser reclaiming the GPU), the canvas +can't show anything new, while the project stays open and commands keep working. The page then +covers the canvas with a notice (`showDeviceLost` in `js/host.js`) offering **Save Project** (a +download) and **Reload**, which first writes pending changes to browser storage +(`effectcraft.flush()`) so the session comes back after the reload; when the write fails it says +so and offers **Reload Anyway**. + **GPU effects run in the frame workers.** A browser never lets JavaScript (or wasm) wait for a GPU readback, not even in a worker: a buffer maps only once the thread returns to its event loop. The CPU renderer, which runs GPU effect chains and reads their results back mid-frame, therefore @@ -418,6 +426,11 @@ node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out tar reused worker again even at the same size, and a worker that fails to start fails its job and is terminated (`node apps/effectcraft-web/tests/workers.mjs`). +`apps/effectcraft-web/tests/page.mjs` checks page-side helpers of `js/host.js` the same way with +a fake DOM: a picked file that can't be read is reported while the others are added, and the +device-lost notice saves, flushes before reloading and reports a failed flush +(`node apps/effectcraft-web/tests/page.mjs`). + It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that replies when done while the page keeps running, and the Media Browser over browser storage; and