modelbeast/web/src/App.jsx
MODELBEAST 7ea3c8b935 fal panel: 5 new operators, image outputs, grouped UI, no-input ops + review fixes
Operators (16 total):
- fal_hunyuan3d_v21: Hunyuan3D 2.1 single-image (live-verified dash variant,
  ~90s; v21 multi-view is broken on fal — v2 stays the multi-view path)
- fal_bg_remove (BiRefNet v2): subject cutout pre-pass — the biggest quality
  lever before image->3D
- fal_upscale (SeedVR faithful upscale), fal_image_edit (nano-banana prompt
  edits), fal_text_image (Ideogram v3 — first no-input operator)
- fal_common: collect='images' mode; recursive URL extractor now takes the
  wanted extension set

UI: operator dropdown grouped by category (optgroup); operators with
accepts: [] run without a selected asset ('No input asset needed' note);
run-button/launch logic updated accordingly.

Review fixes (Opus Phase 1 commit):
- runner._run_lane crashed (TypeError) when a queued job was deleted before
  the worker picked it up
- PUT /api/settings treated empty string as a masked placeholder, making
  secrets impossible to clear from the UI
- store.register_file mutated the caller's meta dict via pop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 21:51:16 +10:00

270 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
api, assetFileURL, cancelJob, connectWS, deleteAsset, deleteJob, getSettings,
listAssets, listJobs, listOperators, retryJob, runJob, uploadFile,
} from "./api";
import Viewer from "./Viewer";
import SplatViewer from "./SplatViewer";
import Settings from "./Settings";
const MODEL_EXTS = ["glb", "gltf", "obj", "fbx", "ply", "stl"];
const fmtSize = (n) =>
n > 1e9 ? (n / 1e9).toFixed(1) + " GB" : n > 1e6 ? (n / 1e6).toFixed(1) + " MB"
: n > 1e3 ? (n / 1e3).toFixed(0) + " KB" : n + " B";
const KIND_ICON = { video: "🎬", image: "🖼", model: "🧊", splat: "✨", frames: "🎞", colmap_dataset: "📐", folder: "📁", other: "📄" };
const isViewable = (a) => a && (a.kind === "splat" || MODEL_EXTS.includes(a.name.split(".").pop().toLowerCase()));
function ParamForm({ schema, values, onChange }) {
const props = schema?.properties || {};
if (!Object.keys(props).length) return <p className="dim">No parameters.</p>;
return (
<div className="params">
{Object.entries(props).map(([key, def]) => {
const val = values[key] ?? def.default ?? "";
const set = (v) => onChange({ ...values, [key]: v });
return (
<label key={key} title={def.description || ""}>
<span>{key}</span>
{def.enum ? (
<select value={val} onChange={(e) => set(maybeNum(def, e.target.value))}>
{def.enum.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
) : def.type === "boolean" ? (
<input type="checkbox" checked={!!val} onChange={(e) => set(e.target.checked)} />
) : def.type === "number" || def.type === "integer" ? (
<input type="number" value={val} step={def.type === "integer" ? 1 : "any"}
min={def.minimum} max={def.maximum}
onChange={(e) => set(e.target.value === "" ? "" : (def.type === "integer" ? parseInt(e.target.value) : parseFloat(e.target.value)))} />
) : (
<input type="text" value={val} onChange={(e) => set(e.target.value)} />
)}
</label>
);
})}
</div>
);
}
const maybeNum = (def, v) => (def.type === "integer" ? parseInt(v) : def.type === "number" ? parseFloat(v) : v);
function AssetPreview({ asset }) {
const [folder, setFolder] = useState(null);
const ext = asset ? asset.name.split(".").pop().toLowerCase() : "";
useEffect(() => {
setFolder(null);
if (asset && (asset.kind === "frames" || asset.kind === "folder" || asset.kind === "colmap_dataset")) {
api(assetFileURL(asset.id)).then(setFolder).catch(() => {});
}
}, [asset?.id]);
if (!asset) return <div className="preview empty">Select an asset or drop / paste files anywhere</div>;
if (asset.kind === "splat") return <SplatViewer url={assetFileURL(asset.id)} />;
if (MODEL_EXTS.includes(ext)) return <Viewer url={assetFileURL(asset.id)} ext={ext} />;
if (asset.kind === "image") return <div className="preview"><img src={assetFileURL(asset.id)} alt={asset.name} /></div>;
if (asset.kind === "video") return <div className="preview"><video src={assetFileURL(asset.id)} controls /></div>;
if (folder?.files) {
const imgs = folder.files.filter((f) => /\.(jpg|jpeg|png)$/i.test(f));
return (
<div className="preview grid">
{imgs.slice(0, 60).map((f) => <img key={f} src={assetFileURL(asset.id, f)} loading="lazy" alt={f} />)}
{imgs.length > 60 && <span className="dim">+{imgs.length - 60} more</span>}
{!imgs.length && <span className="dim">{folder.files.length} files (no image preview)</span>}
</div>
);
}
if (ext === "json") return <JsonPreview url={assetFileURL(asset.id)} />;
return <div className="preview empty">No preview for .{ext} download to use it</div>;
}
function JsonPreview({ url }) {
const [text, setText] = useState("…");
useEffect(() => { fetch(url).then((r) => r.text()).then((t) => setText(t.slice(0, 20000))); }, [url]);
return <pre className="preview json">{text}</pre>;
}
function CompareGrid({ assets }) {
return (
<div className="compare-grid">
{assets.map((a) => (
<div key={a.id} className="compare-cell">
<div className="compare-cap">{a.name} · {fmtSize(a.size)}{a.meta?.tool ? ` · ${a.meta.tool}` : ""}</div>
{a.kind === "splat"
? <SplatViewer url={assetFileURL(a.id)} />
: <Viewer url={assetFileURL(a.id)} ext={a.name.split(".").pop().toLowerCase()} />}
</div>
))}
</div>
);
}
export default function App() {
const [assets, setAssets] = useState([]);
const [operators, setOperators] = useState([]);
const [jobs, setJobs] = useState([]);
const [settings, setSettings] = useState({ _env_set: [] });
const [selected, setSelected] = useState(null);
const [opId, setOpId] = useState(null);
const [params, setParams] = useState({});
const [openJob, setOpenJob] = useState(null);
const [showSettings, setShowSettings] = useState(false);
const [compareMode, setCompareMode] = useState(false);
const [compareSel, setCompareSel] = useState([]);
const fileInput = useRef(null);
const refreshAssets = useCallback(() => listAssets().then(setAssets), []);
const refreshJobs = useCallback(() => listJobs().then(setJobs), []);
const refreshSettings = useCallback(() => getSettings().then(setSettings), []);
useEffect(() => {
refreshAssets(); refreshJobs(); refreshSettings();
listOperators().then(setOperators);
connectWS((msg) => {
if (msg.type === "job") {
setJobs((js) => {
const i = js.findIndex((j) => j.id === msg.job.id);
if (i === -1) return [msg.job, ...js];
const copy = [...js]; copy[i] = msg.job; return copy;
});
}
if (msg.type === "assets_changed") refreshAssets();
});
}, []);
useEffect(() => {
const drop = async (e) => { e.preventDefault(); for (const f of e.dataTransfer?.files || []) await uploadFile(f); refreshAssets(); };
const paste = async (e) => { for (const it of e.clipboardData?.items || []) { const f = it.getAsFile?.(); if (f) await uploadFile(f); } refreshAssets(); };
const prevent = (e) => e.preventDefault();
window.addEventListener("drop", drop); window.addEventListener("dragover", prevent); window.addEventListener("paste", paste);
return () => { window.removeEventListener("drop", drop); window.removeEventListener("dragover", prevent); window.removeEventListener("paste", paste); };
}, [refreshAssets]);
const selectedAsset = assets.find((a) => a.id === selected) || null;
const envSet = settings._env_set || [];
// no-input operators (accepts: []) are always available; others filter by asset kind
const usableOps = useMemo(() =>
operators.filter((op) => !op.accepts.length || !selectedAsset || op.accepts.includes(selectedAsset.kind)),
[operators, selectedAsset]);
const opGroups = useMemo(() => {
const groups = {};
for (const op of usableOps) (groups[op.category || "other"] ||= []).push(op);
return groups;
}, [usableOps]);
const activeOp = operators.find((o) => o.id === opId);
const needsInput = (activeOp?.accepts?.length ?? 0) > 0;
const gatedEnv = (activeOp?.requires_env || []).filter((e) => !envSet.includes(e));
const gated = gatedEnv.length > 0;
useEffect(() => {
if (activeOp) {
const defaults = {};
for (const [k, d] of Object.entries(activeOp.params_schema?.properties || {}))
if (d.default !== undefined) defaults[k] = d.default;
setParams(defaults);
}
}, [opId]);
useEffect(() => {
if (usableOps.length && !usableOps.find((o) => o.id === opId)) setOpId(usableOps[0].id);
}, [usableOps]);
const launch = async () => {
if (!activeOp || gated || (needsInput && !selectedAsset)) return;
await runJob(activeOp.id, needsInput ? selectedAsset.id : null, params);
refreshJobs();
};
const clickAsset = (a) => {
if (compareMode && isViewable(a)) {
setCompareSel((s) => s.includes(a.id) ? s.filter((x) => x !== a.id) : [...s, a.id]);
} else {
setSelected(a.id);
}
};
const compareAssets = compareSel.map((id) => assets.find((a) => a.id === id)).filter(Boolean);
return (
<div className="layout">
<header>
<h1>MODEL<span>BEAST</span></h1>
<span className="dim">M3 Ultra · 256GB · local-first 3D factory</span>
<div className="spacer" />
<button className={compareMode ? "active" : ""} onClick={() => { setCompareMode(!compareMode); setCompareSel([]); }}>
Compare{compareSel.length ? ` (${compareSel.length})` : ""}
</button>
<button onClick={() => setShowSettings(true)}> Settings</button>
<button onClick={() => fileInput.current.click()}>+ Add files</button>
<input ref={fileInput} type="file" multiple hidden
onChange={async (e) => { for (const f of e.target.files) await uploadFile(f); e.target.value = ""; refreshAssets(); }} />
</header>
<aside className="assets">
<h2>Assets</h2>
{assets.map((a) => {
const checked = compareSel.includes(a.id);
return (
<div key={a.id}
className={"asset" + (a.id === selected && !compareMode ? " sel" : "") + (checked ? " checked" : "")}
onClick={() => clickAsset(a)}>
{compareMode && <input type="checkbox" checked={checked} readOnly disabled={!isViewable(a)} />}
<span className="icon">{KIND_ICON[a.kind] || "📄"}</span>
<div className="meta">
<div className="name">{a.name}</div>
<div className="dim">{a.kind} · {fmtSize(a.size)}</div>
</div>
<a className="dl" href={assetFileURL(a.id)} download onClick={(e) => e.stopPropagation()} title="download"></a>
<button className="x" title="delete" onClick={(e) => { e.stopPropagation(); deleteAsset(a.id).then(refreshAssets); if (selected === a.id) setSelected(null); }}>×</button>
</div>
);
})}
{!assets.length && <p className="dim">Drop videos, images, or 3D files anywhere.</p>}
</aside>
<main>
{compareMode && compareAssets.length
? <CompareGrid assets={compareAssets} />
: <AssetPreview asset={selectedAsset} />}
</main>
<aside className="workbench">
<h2>Run</h2>
<select value={opId || ""} onChange={(e) => setOpId(e.target.value)}>
{Object.entries(opGroups).map(([cat, ops]) => (
<optgroup key={cat} label={cat}>
{ops.map((op) => <option key={op.id} value={op.id}>{op.name}</option>)}
</optgroup>
))}
</select>
{activeOp && <p className="dim">{activeOp.description}</p>}
{gated && <p className="warn">🔒 Needs {gatedEnv.join(", ")} add it in Settings.</p>}
{activeOp && !needsInput && <p className="dim">No input asset needed just set the parameters and run.</p>}
{activeOp && <ParamForm schema={activeOp.params_schema} values={params} onChange={setParams} />}
<button className="go" disabled={!activeOp || gated || (needsInput && !selectedAsset)} onClick={launch}>
Run {activeOp?.name || ""}
</button>
<h2>Jobs</h2>
<div className="jobs">
{jobs.map((j) => (
<div key={j.id} className={"job " + j.status}>
<div className="job-row" onClick={() => setOpenJob(openJob === j.id ? null : j.id)}>
<span className="status">{j.status === "running" ? "⏳" : j.status === "done" ? "✅" : j.status === "error" ? "❌" : j.status === "cancelled" ? "⛔" : "🕐"}</span>
<span className="jop">{j.operator}</span>
<span className="dim jid">{j.id}</span>
<span className="job-actions" onClick={(e) => e.stopPropagation()}>
{j.status === "running" && <button onClick={() => cancelJob(j.id)}>cancel</button>}
{(j.status === "error" || j.status === "done" || j.status === "cancelled") && <button onClick={() => retryJob(j.id).then(refreshJobs)}>retry</button>}
{j.status !== "running" && <button onClick={() => deleteJob(j.id).then(refreshJobs)}>×</button>}
</span>
</div>
{openJob === j.id && (
<pre className="log">{j.error ? `ERROR: ${j.error}\n\n` : ""}{j.log || "(no output yet)"}</pre>
)}
</div>
))}
{!jobs.length && <p className="dim">No jobs yet.</p>}
</div>
</aside>
{showSettings && <Settings onClose={() => setShowSettings(false)} onSaved={setSettings} />}
</div>
);
}