232 lines
8.6 KiB
JavaScript
232 lines
8.6 KiB
JavaScript
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>
|
||
);
|
||
}
|