modelbeast/web/src/App.jsx
2026-07-12 21:05:22 +10:00

232 lines
8.6 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, connectWS, deleteAsset, listAssets, listJobs,
listOperators, runJob, uploadFile,
} from "./api";
import Viewer from "./Viewer";
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: "🧊", frames: "🎞", other: "📄" };
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(e.target.value)}>
{def.enum.map((o) => <option key={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(def.type === "integer" ? parseInt(e.target.value || 0) : parseFloat(e.target.value || 0))} />
) : (
<input type="text" value={val} onChange={(e) => set(e.target.value)} />
)}
</label>
);
})}
</div>
);
}
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")) {
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 (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)
return (
<div className="preview grid">
{folder.files.slice(0, 60).map((f) => (
<img key={f} src={assetFileURL(asset.id, f)} loading="lazy" alt={f} />
))}
{folder.files.length > 60 && <span className="dim">+{folder.files.length - 60} more</span>}
</div>
);
if (ext === "json")
return <JsonPreview url={assetFileURL(asset.id)} />;
return <div className="preview empty">No preview for .{ext}</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>;
}
export default function App() {
const [assets, setAssets] = useState([]);
const [operators, setOperators] = useState([]);
const [jobs, setJobs] = useState([]);
const [selected, setSelected] = useState(null);
const [opId, setOpId] = useState(null);
const [params, setParams] = useState({});
const [openJob, setOpenJob] = useState(null);
const fileInput = useRef(null);
const refreshAssets = useCallback(() => listAssets().then(setAssets), []);
const refreshJobs = useCallback(() => listJobs().then(setJobs), []);
useEffect(() => {
refreshAssets();
refreshJobs();
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();
});
}, []);
// drag-drop + paste ingest
useEffect(() => {
const drop = async (e) => {
e.preventDefault();
for (const f of e.dataTransfer?.files || []) await uploadFile(f);
refreshAssets();
};
const paste = async (e) => {
const items = [...(e.clipboardData?.items || [])];
for (const it of 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 usableOps = useMemo(() =>
operators.filter((op) => !selectedAsset || op.accepts.includes(selectedAsset.kind)),
[operators, selectedAsset]);
const activeOp = operators.find((o) => o.id === opId);
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 || !selectedAsset) return;
await runJob(activeOp.id, selectedAsset.id, params);
refreshJobs();
};
return (
<div className="layout">
<header>
<h1>MODEL<span>BEAST</span></h1>
<span className="dim">M3 Ultra · 256GB · local-first 3D factory</span>
<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) => (
<div key={a.id}
className={"asset" + (a.id === selected ? " sel" : "")}
onClick={() => setSelected(a.id)}>
<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>
<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>
<AssetPreview asset={selectedAsset} />
</main>
<aside className="workbench">
<h2>Run</h2>
<select value={opId || ""} onChange={(e) => setOpId(e.target.value)}>
{usableOps.map((op) => <option key={op.id} value={op.id}>{op.name}</option>)}
</select>
{activeOp && <p className="dim">{activeOp.description}</p>}
{activeOp && <ParamForm schema={activeOp.params_schema} values={params} onChange={setParams} />}
<button className="go" disabled={!selectedAsset || !activeOp} onClick={launch}>
Run {activeOp?.name || ""}
</button>
<h2>Jobs</h2>
<div className="jobs">
{jobs.map((j) => (
<div key={j.id} className={"job " + j.status}
onClick={() => setOpenJob(openJob === j.id ? null : j.id)}>
<span className="status">{j.status === "running" ? "⏳" : j.status === "done" ? "✅" : j.status === "error" ? "❌" : "🕐"}</span>
<span>{j.operator}</span>
<span className="dim">{j.id}</span>
{openJob === j.id && (
<pre className="log">{j.error ? `ERROR: ${j.error}\n` : ""}{j.log || "(no output yet)"}</pre>
)}
</div>
))}
{!jobs.length && <p className="dim">No jobs yet.</p>}
</div>
</aside>
</div>
);
}