mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #237 from storytold/fix/225-226-231-web
Web: short Project panel, unreadable picked files, lost graphics device (#231, #226, #225)
This commit is contained in:
9 files changed
+283
-22
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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<dyn FnOnce(&mut EffectcraftApp)>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::<js_sys::Uint8Array>().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::<js_sys::Uint8Array>().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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,8 @@ pub fn last_error() -> Option<String> {
|
||||
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:?}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user