/3d beside /images, while some model makes meshes: a picture chosen, the
job asked for through the same path an API client's takes, its progress
shown (waiting for the card, n ahead; making it, the node and percent),
and the mesh in a viewer — model-viewer 4.4.0 (Apache 2.0, with three.js
and lit), served from the gateway since the page's policy loads scripts
from nowhere else — with a download and the model's licence. The mesh is
served to its owner from /3d/jobs/{id}/file; another project's person is
told there is no such job.
A job made from the portal acted for a key that does not exist: the
worker now rebuilds a session's principal from the account that made it,
while it is active and still in the project the job is charged to.
ctl 3d list|get|cancel, over /api/v1/client-jobs (/api/v1/jobs is the
control plane's own work, which ctl jobs shows).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
179 lines
7.2 KiB
HTML
179 lines
7.2 KiB
HTML
{{define "meshes"}}<!doctype html>
|
|
<html lang="{{lang}}"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>{{t "meshes.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
|
<script defer src="{{asset "pages.js"}}"></script>
|
|
<script type="module" src="{{asset "model-viewer.min.js"}}"></script>
|
|
<meta name="csrf" content="{{.CSRF}}">
|
|
</head><body><div class="wrap">
|
|
<header>
|
|
<h1>{{t "meshes.title"}}</h1>
|
|
<p class="sub">{{.User.Email}}</p>
|
|
{{template "nav" .Nav}}
|
|
</header>
|
|
|
|
{{if gt (len .Contexts) 1}}
|
|
<form method="get" action="/3d" class="chat-context">
|
|
<label class="inline-label">{{t "chat.context.using"}}
|
|
<select id="context" name="context" data-autosubmit>
|
|
{{range .Contexts}}<option value="{{.Value}}"{{if eq .Value $.Context}} selected{{end}}>{{.Label}}</option>{{end}}
|
|
</select></label>
|
|
<noscript translate="no"><button type="submit" class="link">{{t "chat.context.switch"}}</button></noscript>
|
|
</form>
|
|
{{else if .Contexts}}{{with index .Contexts 0}}<input type="hidden" id="context" value="{{.Value}}">{{end}}{{end}}
|
|
|
|
{{if .Models}}
|
|
<form id="make" class="images-form">
|
|
<div class="chat-bar">
|
|
<label class="inline-label">{{t "chat.model"}}
|
|
<select id="model">{{range .Models}}<option value="{{.Name}}">{{.Name}}{{with .Runs}} · {{.}}{{end}}</option>{{end}}</select>
|
|
</label>
|
|
<label class="inline-label">{{t "meshes.resolution"}}
|
|
<select id="resolution" title="{{t "meshes.resolution_hint"}}">
|
|
<option value="">—</option><option>192</option><option>256</option><option>384</option><option>512</option>
|
|
</select></label>
|
|
<label class="inline-label">{{t "meshes.seed"}}
|
|
<input type="number" id="seed" min="0" step="1" class="images-num"></label>
|
|
</div>
|
|
<label class="meshes-picture">{{t "meshes.picture"}} <span class="opt">{{t "meshes.picture_hint"}}</span>
|
|
<input type="file" id="picture" accept="image/png,image/jpeg,image/webp"></label>
|
|
<img id="preview" class="meshes-preview" alt="" hidden>
|
|
<div class="chat-composer"><button type="submit" id="go">{{t "meshes.make"}}</button></div>
|
|
</form>
|
|
<p id="status" class="note" aria-live="polite"></p>
|
|
<div id="gallery" class="images-gallery"></div>
|
|
<p class="note">{{t "meshes.kept"}}</p>
|
|
{{else}}
|
|
<p class="empty">{{t "meshes.empty"}}</p>
|
|
<p class="note">{{th "meshes.empty_why"}}</p>
|
|
{{end}}
|
|
|
|
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
|
|
</div>
|
|
|
|
<script>
|
|
const say = {
|
|
queued: {{t "meshes.js.queued"}},
|
|
queuedNone: {{t "meshes.js.queued_none"}},
|
|
running: {{t "meshes.js.running"}},
|
|
done: {{t "meshes.js.done"}},
|
|
failed: {{t "meshes.js.failed"}},
|
|
download: {{t "meshes.js.download"}},
|
|
license: {{t "meshes.js.license"}},
|
|
noPicture: {{t "meshes.js.no_picture"}},
|
|
decimal: {{t "_meta.decimal"}},
|
|
};
|
|
function fill(s, vals) { return s.replace(/\{(\w+)\}/g, (m, k) => (k in vals ? String(vals[k]) : m)); }
|
|
function fixed(n, places) { return n.toFixed(places).replace(".", say.decimal); }
|
|
// A 3D model is a job: asked for, then asked after until it is made. The
|
|
// mesh is shown from the gateway itself, which the page's policy allows.
|
|
(function () {
|
|
const form = document.getElementById("make");
|
|
if (!form) return;
|
|
const csrf = document.querySelector('meta[name="csrf"]').content;
|
|
const $ = (id) => document.getElementById(id);
|
|
const status = $("status"), gallery = $("gallery"), go = $("go"), preview = $("preview");
|
|
let picture = "";
|
|
const ctx = () => ($("context") ? $("context").value : "");
|
|
|
|
$("picture").addEventListener("change", () => {
|
|
const f = $("picture").files[0];
|
|
if (!f) return;
|
|
const rd = new FileReader();
|
|
rd.addEventListener("load", () => {
|
|
picture = rd.result;
|
|
preview.src = picture;
|
|
preview.hidden = false;
|
|
});
|
|
rd.readAsDataURL(f);
|
|
});
|
|
|
|
function describe(j) {
|
|
const p = j.progress || {};
|
|
if (j.status === "queued" || p.state === "queued" || !p.state) {
|
|
return p.ahead ? fill(say.queued, { ahead: p.ahead }) : say.queuedNone;
|
|
}
|
|
return fill(say.running, { node: p.node_class || "", percent: p.percent || 0 });
|
|
}
|
|
|
|
async function follow(id, started) {
|
|
const q = ctx() ? "?context=" + encodeURIComponent(ctx()) : "";
|
|
for (;;) {
|
|
const resp = await fetch("/3d/jobs/" + encodeURIComponent(id) + q);
|
|
const j = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) throw new Error((j.error && j.error.message) || String(resp.status));
|
|
if (j.status === "completed") return j;
|
|
if (["failed", "cancelled", "expired"].includes(j.status)) {
|
|
throw new Error((j.error && j.error.message) || j.status);
|
|
}
|
|
status.textContent = describe(j);
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
}
|
|
}
|
|
|
|
function show(j, took) {
|
|
const q = ctx() ? "?context=" + encodeURIComponent(ctx()) : "";
|
|
const src = "/3d/jobs/" + encodeURIComponent(j.id) + "/file" + q;
|
|
const fig = document.createElement("figure");
|
|
const mv = document.createElement("model-viewer");
|
|
mv.setAttribute("src", src);
|
|
mv.setAttribute("camera-controls", "");
|
|
mv.setAttribute("auto-rotate", "");
|
|
mv.setAttribute("shadow-intensity", "0.6");
|
|
mv.className = "meshes-viewer";
|
|
const cap = document.createElement("figcaption");
|
|
const line = document.createElement("span");
|
|
line.textContent = j.model + " · " + j.id + " · " + fill(say.done, { s: fixed(took, 0) });
|
|
const dl = document.createElement("a");
|
|
dl.href = src;
|
|
dl.download = j.id + ".glb";
|
|
dl.className = "link";
|
|
dl.textContent = say.download;
|
|
cap.append(line, " ", dl);
|
|
fig.append(mv, cap);
|
|
if (j.license) {
|
|
const lic = document.createElement("p");
|
|
lic.className = "images-prompt";
|
|
lic.textContent = say.license + ": " + j.license.name + (j.license.notice ? " — " + j.license.notice : "");
|
|
fig.append(lic);
|
|
}
|
|
gallery.prepend(fig);
|
|
}
|
|
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
if (go.disabled) return;
|
|
if (!picture) { status.textContent = say.noPicture; return; }
|
|
const body = { model: $("model").value, image: picture, context: ctx() };
|
|
if ($("seed").value !== "") body.seed = Number($("seed").value);
|
|
if ($("resolution").value !== "") body.resolution = Number($("resolution").value);
|
|
go.disabled = true;
|
|
status.classList.remove("error");
|
|
status.textContent = say.queuedNone;
|
|
const start = performance.now();
|
|
try {
|
|
const resp = await fetch("/3d/generate", {
|
|
method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const text = await resp.text();
|
|
let j = null;
|
|
try { j = JSON.parse(text); } catch (_) {}
|
|
if (!resp.ok || !j || !j.id) {
|
|
throw new Error(String((j && j.error && j.error.message) || text.trim() || resp.status));
|
|
}
|
|
const done = await follow(j.id, start);
|
|
const took = (performance.now() - start) / 1000;
|
|
status.textContent = fill(say.done, { s: fixed(took, 0) });
|
|
show(done, took);
|
|
} catch (err) {
|
|
status.textContent = fill(say.failed, { why: err.message });
|
|
status.classList.add("error");
|
|
} finally {
|
|
go.disabled = false;
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
</body></html>{{end}}
|