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>
270 lines
13 KiB
JavaScript
270 lines
13 KiB
JavaScript
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>
|
||
);
|
||
}
|