Files
effectcraft/apps/effectcraft-web/src/browse.rs
T
Brandon ThomasandClaude Opus 5.5 5bb6768766 M13.10: web frame workers fed by project diffs, offloaded Roto Brush propagation, non-blocking wait jobs, browser Media Browser
Viewer frames render in long-lived Web Workers holding a project replica
(engine::remote: structural JSON diff/patch, Mirror, FrameServer); the
frame cache hands jobs to a RemoteFrames renderer (UI-thread fallback).
Roto Brush propagation runs in a job worker and streams segmentations
back (FrameSeg binary form), relayed to frame workers. Control-channel
wait:true commands whose job is offloaded reply when it ends. The Media
Browser browses a pluggable Browser: the file system on the desktop;
browser storage and File System Access folders on the web.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:36:00 -04:00

278 lines
11 KiB
Rust

//! The Media Browser in the browser ([`effectcraft_engine::media_browser::Browser`]).
//!
//! It browses a virtual tree:
//!
//! - **Browser Storage** (`/Browser Storage/…`): every file in the persistent file table (OPFS /
//! IndexedDB): imported media, uploaded files and folders, saved projects. Files added with
//! **Add Files…** / **Upload Folder…** (plain `<input type=file>`, so every browser) are copied
//! there and stay across visits: the recent list.
//! - **Folders** (`/Folders/<name>/…`): folders opened with **Open Folder…** through the File
//! System Access API (`showDirectoryPicker`, Chromium browsers). Only their listing is read;
//! a file's bytes are read when it is imported ([`Browser::fetch`]) and then kept in the file
//! table. The folder handles are remembered (IndexedDB), so after a reload the folders come
//! back once the browser grants access again (**Reconnect <name>**).
use std::sync::{LazyLock, Mutex};
use effectcraft_engine::media_browser::{Browser, Entry, VirtualTree};
use serde_json::{Value, json};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/js/host.js")]
extern "C" {
#[wasm_bindgen(js_name = browseCanPickFolder)]
fn can_pick_folder() -> bool;
#[wasm_bindgen(js_name = browsePickFolder)]
fn pick_folder() -> js_sys::Promise;
#[wasm_bindgen(js_name = browseRestore)]
fn restore_folders() -> js_sys::Promise;
#[wasm_bindgen(js_name = browseReconnect)]
fn reconnect(name: &str) -> js_sys::Promise;
#[wasm_bindgen(js_name = browseForget)]
fn forget(name: &str) -> js_sys::Promise;
#[wasm_bindgen(js_name = browseRead)]
fn read(path: &str) -> js_sys::Promise;
#[wasm_bindgen(js_name = browsePickFiles)]
fn pick_files(directory: bool) -> js_sys::Promise;
}
const STORAGE: &str = "/Browser Storage";
const FOLDERS: &str = "/Folders";
#[derive(Default)]
struct State {
/// Opened folders' files (`/Folders/…`).
folders: VirtualTree,
/// Opened folder names, and remembered ones waiting for access.
open: Vec<String>,
disconnected: Vec<String>,
}
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())
}
/// The web app's Media Browser.
pub struct WebBrowser;
/// The stored files as a tree under `/Browser Storage` (rebuilt per listing: storage changes).
fn storage_tree() -> VirtualTree {
let mut t = VirtualTree::default();
t.add_dir(STORAGE);
for (path, size, modified, persisted) in crate::files::STORE.files() {
if !persisted || path.starts_with(FOLDERS) {
continue;
}
t.add_file(&format!("{STORAGE}{path}"), &path, size as u64, Some((modified / 1000.0) as u64));
}
t
}
/// Add an opened folder's listing (`[{path, dir, size, modified}]`, paths `name/…`).
fn add_folder(v: &Value) -> Option<String> {
let name = v["name"].as_str()?.to_string();
let mut st = state();
st.folders.remove(&format!("{FOLDERS}/{name}"));
st.folders.add_dir(&format!("{FOLDERS}/{name}"));
for e in v["entries"].as_array().into_iter().flatten() {
let Some(rel) = e["path"].as_str() else { continue };
let path = format!("{FOLDERS}/{rel}");
if e["dir"].as_bool() == Some(true) {
st.folders.add_dir(&path);
} else {
st.folders.add_file(&path, &path, e["size"].as_u64().unwrap_or(0), e["modified"].as_u64());
}
}
st.open.retain(|n| *n != name);
st.open.push(name.clone());
st.disconnected.retain(|n| *n != name);
Some(name)
}
fn to_value(v: &JsValue) -> Value {
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(Value::Null)
}
/// Run a promise; on success, `then` gets its value (as JSON) on the UI thread.
fn spawn(p: js_sys::Promise, what: &'static str, then: impl FnOnce(&mut effectcraft_ui_egui::EffectcraftApp, Value) + 'static) {
wasm_bindgen_futures::spawn_local(async move {
match wasm_bindgen_futures::JsFuture::from(p).await {
Ok(v) => {
let v = to_value(&v);
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();
// Cancelling a picker is not an error.
if !msg.contains("abort") && !msg.contains("AbortError") {
crate::post(move |app| app.ui.status = format!("{what}: {msg}"));
}
}
}
});
}
fn go(app: &mut effectcraft_ui_egui::EffectcraftApp, path: &str) {
if let Err(e) = app.session.execute("mediaBrowser.go", json!({"path": path})) {
app.ui.status = e.to_string();
}
}
/// Bring back the folders opened on earlier visits (those the browser still grants access to).
pub fn restore() {
spawn(restore_folders(), "Media Browser", |_, v| {
for f in v.as_array().into_iter().flatten() {
if f["granted"].as_bool() == Some(true) {
add_folder(f);
} else if let Some(n) = f["name"].as_str() {
let mut st = state();
if !st.disconnected.iter().any(|d| d == n) {
st.disconnected.push(n.to_string());
}
}
}
});
}
impl Browser for WebBrowser {
fn home(&self) -> String {
STORAGE.into()
}
fn places(&self) -> Vec<(String, String)> {
let mut v = vec![("Browser Storage".to_string(), STORAGE.to_string())];
v.extend(state().open.iter().map(|n| (n.clone(), format!("{FOLDERS}/{n}"))));
v
}
fn list(&self, dir: &str, importable_only: bool) -> Result<Vec<Entry>, String> {
let dir = if dir.len() > 1 { dir.trim_end_matches('/') } else { dir };
if dir == STORAGE || dir.starts_with(&format!("{STORAGE}/")) {
return storage_tree().list(dir, importable_only);
}
if dir == "/" {
let mut t = VirtualTree::default();
t.add_dir(STORAGE);
t.add_dir(FOLDERS);
return t.list("/", false);
}
let st = state();
if dir == FOLDERS && st.open.is_empty() {
return Ok(vec![]);
}
st.folders.list(dir, importable_only).map_err(|e| {
let name = dir.trim_start_matches(FOLDERS).trim_start_matches('/').split('/').next().unwrap_or("");
if st.disconnected.iter().any(|d| d == name) { format!("{name}: click Reconnect {name} to allow access again") } else { e }
})
}
fn parent(&self, dir: &str) -> Option<String> {
let d = dir.trim_end_matches('/');
match d.rfind('/') {
Some(0) if d.len() > 1 => Some("/".into()),
Some(i) if i > 0 => Some(d[..i].to_string()),
_ => None,
}
}
fn fetch(&self, paths: &[String], params: &Value) -> bool {
let missing: Vec<String> = paths.iter().filter(|p| p.starts_with(&format!("{FOLDERS}/")) && crate::files::get(p).is_none()).cloned().collect();
if missing.is_empty() {
return true;
}
let params = params.clone();
wasm_bindgen_futures::spawn_local(async move {
for p in &missing {
match wasm_bindgen_futures::JsFuture::from(read(p)).await {
Ok(b) => crate::files::put(p, js_sys::Uint8Array::new(&b).to_vec().into()),
Err(e) => {
let msg = format!("{p}: {}", e.as_string().unwrap_or_else(|| "cannot read the file (reconnect its folder)".into()));
crate::post(move |app| app.ui.status = msg);
return;
}
}
}
crate::post(move |app| match app.session.execute("mediaBrowser.import", params) {
Ok(r) => {
if let Some(errs) = r["errors"].as_array().filter(|e| !e.is_empty()) {
app.ui.status = errs.iter().filter_map(|e| e.as_str()).collect::<Vec<_>>().join("; ");
}
}
Err(e) => app.ui.status = e.to_string(),
});
});
false
}
fn actions(&self) -> Vec<(String, String)> {
let mut v = vec![];
if can_pick_folder() {
v.push(("openFolder".to_string(), "Open Folder…".to_string()));
}
v.push(("addFiles".into(), "Add Files…".into()));
v.push(("uploadFolder".into(), "Upload Folder…".into()));
for n in &state().disconnected {
v.push((format!("reconnect:{n}"), format!("Reconnect {n}")));
}
v
}
fn action(&self, id: &str) -> Result<Value, String> {
match id {
"openFolder" => {
if !can_pick_folder() {
return Err("this browser cannot open folders (use Add Files… or Upload Folder…)".into());
}
spawn(pick_folder(), "Open Folder", |app, v| {
if let Some(n) = add_folder(&v) {
go(app, &format!("{FOLDERS}/{n}"));
}
});
Ok(json!({"pending": true}))
}
"addFiles" | "uploadFolder" => {
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;
}
}
crate::post(move |app| {
app.ui.status = format!("{n} file(s) added to Browser Storage");
go(app, STORAGE);
});
});
Ok(json!({"pending": true}))
}
_ => {
if let Some(name) = id.strip_prefix("reconnect:") {
spawn(reconnect(name), "Reconnect", |app, v| {
if let Some(n) = add_folder(&v) {
go(app, &format!("{FOLDERS}/{n}"));
}
});
return Ok(json!({"pending": true}));
}
if let Some(name) = id.strip_prefix("forget:") {
let _ = forget(name);
let mut st = state();
st.open.retain(|n| n != name);
st.disconnected.retain(|n| n != name);
st.folders.remove(&format!("{FOLDERS}/{name}"));
return Ok(json!({"forgotten": name}));
}
Err(format!("unknown Media Browser action `{id}`"))
}
}
}
}