Fifteen changes aimed at the first day of real use: - Import and media jobs persist to <data>/jobs-*.json and reload on start (running ones settle as errors); the Register button no longer vanishes because the gateway restarted. - Docker ships the cortiq binary (built in the builder stage) — local .cmf chat models and image/video generation now work in containers, where auto-install never could (no cargo in the runtime image). libvulkan1 added for optional GPU offload. - Open admin console on 0.0.0.0 without a token now warns in the log and shows a dismissible banner — the VPS footgun is at least labeled. - "Restart gateway" button in Settings: stops managed model servers, spawns a fresh copy (bind retried while the port frees), Docker relies on the container restart policy instead. - Dashboard "System" panel: CPU sparkline + load, RAM (with the gateway's own RSS), free disk at the models dir — sampled by sysinfo every 2 s. - Featured model cards warn "not enough disk" / "may not fit in RAM" before the download, from the same live snapshot. - Log viewer in the sidebar: a 500-line in-memory ring fed by a second ANSI-free tracing layer; filter, level, pause, copy. - Update check: crates.io polled twice a day, the console shows an "update available" notice. - Media generations queue up (with position shown) instead of being rejected; progress gained an honest ETA from measured throughput. - Generation gallery under the image/video tabs: lazy PPM thumbnails, click to reopen with seed/steps, delete. - Playground chat is a real dialogue now: history is resent, bubbles, Clear, Cmd/Ctrl+Enter. - "Connect your app" snippets (curl / Python / JS) on the keys page, with the real key inlined right after creation. - One-click benchmark on the Models page via `cortiq bench --json`. - Config export now downloads the actual gateway.toml (comments intact); import accepts TOML (validated, hot-reloaded) and the legacy JSON. - Streaming token counts are exact when the model server provides usage (cortiq-server patch, shipped separately); the gateway fallback now counts content chunks (1/token on cortiq) instead of chars/4. Wizard's Finish lands on the Playground. 37 new strings in all 7 languages.
673 lines
27 KiB
JavaScript
Vendored
673 lines
27 KiB
JavaScript
Vendored
// Playground — three tabs over the live gateway:
|
||
// chat → the routing pipeline (with streaming, token counts, tok/s, TTFT)
|
||
// image → `cortiq imagine` over a local text-to-image .cmf (Lumina)
|
||
// video → `cortiq animate` over a local text-to-video .cmf (MiniMax-H3)
|
||
// Media results are decoded in the browser (PPM → canvas, MJPEG AVI → canvas
|
||
// player synced to the wav) — the same no-ffmpeg stance as the engine itself.
|
||
import { h, mount, toast, money, ms } from "../ui.js";
|
||
import { t } from "../i18n.js";
|
||
import { api, testStream, mediaFileUrl, mediaFileBytes } from "../api.js";
|
||
import { appState } from "../app.js";
|
||
import { ppmToCanvas, fileToPpmB64, aviJpegFrames, mjpegPlayer } from "../media-utils.js";
|
||
|
||
function fmtBytes(n) {
|
||
if (!n && n !== 0) return "—";
|
||
if (n >= 1e9) return (n / 1e9).toFixed(2) + " GB";
|
||
if (n >= 1e6) return (n / 1e6).toFixed(1) + " MB";
|
||
if (n >= 1e3) return Math.round(n / 1e3) + " KB";
|
||
return n + " B";
|
||
}
|
||
function kv(k, v) {
|
||
return h("div", { class: "kv" }, h("span", { class: "k" }, k), h("span", { class: "v" }, v));
|
||
}
|
||
function field(label, control, hint) {
|
||
return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("div", { class: "hint" }, hint) : null);
|
||
}
|
||
|
||
export async function renderPlayground() {
|
||
const meta = appState.meta || (await api.meta());
|
||
const modelsData = await api.listModels();
|
||
const ids = (modelsData.models || []).map((m) => m.id);
|
||
let mediaModels = [];
|
||
try {
|
||
mediaModels = (await api.mediaModels()).models || [];
|
||
} catch {
|
||
/* media API unavailable — tabs will show the hint */
|
||
}
|
||
const imageModels = mediaModels.filter((m) => m.modality === "image");
|
||
const videoModels = mediaModels.filter((m) => m.modality === "video");
|
||
|
||
const root = h("div");
|
||
let tab = "chat";
|
||
|
||
// ── tabs ──
|
||
const tabsSeg = h(
|
||
"div",
|
||
{ class: "seg" },
|
||
h("button", { class: "active", "data-tab": "chat" }, "💬 " + t("pg.tab.chat")),
|
||
h("button", { "data-tab": "image" }, "🖼 " + t("pg.tab.image")),
|
||
h("button", { "data-tab": "video" }, "🎬 " + t("pg.tab.video"))
|
||
);
|
||
const panelHost = h("div", {});
|
||
tabsSeg.querySelectorAll("button").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
tab = b.dataset.tab;
|
||
tabsSeg.querySelectorAll("button").forEach((x) => x.classList.toggle("active", x === b));
|
||
mount(panelHost, panels[tab]);
|
||
})
|
||
);
|
||
|
||
// ════════════════════════════ chat panel ════════════════════════════
|
||
let mode = "auto";
|
||
const sysIn = h("textarea", { placeholder: t("pg.systemPlaceholder"), rows: 2 });
|
||
const promptIn = h("textarea", { placeholder: t("pg.promptPlaceholder"), rows: 5 });
|
||
const tempIn = h("input", { type: "number", step: "0.1", value: 0.7 });
|
||
const maxTokIn = h("input", { type: "number", value: 512 });
|
||
const streamCb = h("input", { type: "checkbox", checked: true });
|
||
const profileSel = h("select", {}, ...(meta.profiles || []).map((p) => h("option", { value: p }, p)));
|
||
const modelSel = h("select", {}, ...ids.map((id) => h("option", { value: id }, id)));
|
||
|
||
const modeSeg = h(
|
||
"div",
|
||
{ class: "seg" },
|
||
h("button", { class: "active", onClick: (e) => setMode("auto", e) }, t("pg.mode.auto")),
|
||
h("button", { onClick: (e) => setMode("pinned", e) }, t("pg.mode.pinned"))
|
||
);
|
||
function buildModeExtra() {
|
||
return h(
|
||
"label",
|
||
{ class: "field" },
|
||
h("span", {}, mode === "auto" ? t("pg.profile") : t("pg.model")),
|
||
mode === "auto" ? profileSel : modelSel
|
||
);
|
||
}
|
||
let modeExtra = buildModeExtra();
|
||
function setMode(m, e) {
|
||
mode = m;
|
||
modeSeg.querySelectorAll("button").forEach((b) => b.classList.remove("active"));
|
||
e.target.classList.add("active");
|
||
const next = buildModeExtra();
|
||
modeExtra.replaceWith(next);
|
||
modeExtra = next;
|
||
}
|
||
|
||
const chatLog = []; // the running conversation: {role, content}
|
||
const out = h("div", { class: "answer chat-log" }, h("div", { class: "empty" }, t("pg.empty")));
|
||
const decision = h("div", { class: "card routecard" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—"));
|
||
|
||
function bubble(role, text) {
|
||
return h("div", { class: "chat-msg " + role }, h("div", { class: "chat-bubble" }, text));
|
||
}
|
||
function renderChat(liveText) {
|
||
const items = chatLog.map((m) => bubble(m.role, m.content));
|
||
if (liveText != null) items.push(bubble("assistant", liveText || "…"));
|
||
mount(out, ...(items.length ? items : [h("div", { class: "empty" }, t("pg.empty"))]));
|
||
out.scrollTop = out.scrollHeight;
|
||
}
|
||
const clearBtn = h("button", { class: "btn ghost sm" }, "🧹 " + t("pg.clearChat"));
|
||
clearBtn.addEventListener("click", () => {
|
||
chatLog.length = 0;
|
||
renderChat();
|
||
mount(decision, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—"));
|
||
});
|
||
|
||
const sendBtn = h("button", { class: "btn primary" }, t("pg.send"));
|
||
sendBtn.addEventListener("click", async () => {
|
||
const prompt = promptIn.value.trim();
|
||
if (!prompt) return;
|
||
const messages = [];
|
||
if (sysIn.value.trim()) messages.push({ role: "system", content: sysIn.value.trim() });
|
||
messages.push(...chatLog, { role: "user", content: prompt });
|
||
let model = "cortiq-auto";
|
||
if (mode === "auto") {
|
||
if (profileSel.value) model = "cortiq-auto:" + profileSel.value;
|
||
} else {
|
||
model = modelSel.value;
|
||
}
|
||
sendBtn.disabled = true;
|
||
sendBtn.textContent = t("pg.sending");
|
||
chatLog.push({ role: "user", content: prompt });
|
||
promptIn.value = "";
|
||
renderChat("");
|
||
const body = {
|
||
model,
|
||
messages,
|
||
temperature: parseFloat(tempIn.value),
|
||
max_tokens: parseInt(maxTokIn.value) || null,
|
||
};
|
||
try {
|
||
if (streamCb.checked) {
|
||
const started = performance.now();
|
||
let acc = "";
|
||
const info = await testStream(body, (d) => {
|
||
acc += d;
|
||
renderChat(acc);
|
||
});
|
||
chatLog.push({ role: "assistant", content: acc || "—" });
|
||
renderChat();
|
||
const latency = Math.round(performance.now() - started);
|
||
renderDecision({
|
||
cortiq: {
|
||
task_label: info.task_label,
|
||
complexity: { score: info.score, tier: info.tier },
|
||
selected_model: info.selected_model,
|
||
route_source: info.route_source,
|
||
cost_usd: info.cost_usd,
|
||
failover: false,
|
||
},
|
||
usage: info.usage || {},
|
||
usage_estimated: info.usage_estimated,
|
||
latency_ms: latency,
|
||
ttft_ms: info.ttft_ms,
|
||
});
|
||
} else {
|
||
const r = await api.test(body);
|
||
if (r.ok) {
|
||
chatLog.push({ role: "assistant", content: r.answer || "—" });
|
||
renderChat();
|
||
renderDecision(r);
|
||
} else {
|
||
chatLog.pop(); // the user turn failed — let them retry it
|
||
renderChat();
|
||
promptIn.value = prompt;
|
||
mount(out, out.firstChild || "", h("div", { class: "callout" }, h("b", {}, t("pg.error") + ": "), r.error || ""));
|
||
mount(decision, h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))), h("div", { class: "empty" }, "—"));
|
||
}
|
||
}
|
||
} catch (e) {
|
||
chatLog.pop();
|
||
renderChat();
|
||
promptIn.value = prompt;
|
||
toast(String(e.message || e), "err");
|
||
}
|
||
sendBtn.disabled = false;
|
||
sendBtn.textContent = t("pg.send");
|
||
});
|
||
// Cmd/Ctrl+Enter sends
|
||
promptIn.addEventListener("keydown", (e) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||
e.preventDefault();
|
||
sendBtn.click();
|
||
}
|
||
});
|
||
|
||
function renderDecision(r) {
|
||
const c = r.cortiq || {};
|
||
const score = c.complexity?.score ?? 0;
|
||
const u = r.usage || {};
|
||
const haveUsage = u.completion_tokens != null;
|
||
// generation speed over the produce phase (after first token when known)
|
||
let tokS = null;
|
||
if (haveUsage && r.latency_ms) {
|
||
const genMs = r.ttft_ms != null ? Math.max(1, r.latency_ms - r.ttft_ms) : r.latency_ms;
|
||
tokS = (u.completion_tokens / (genMs / 1000)).toFixed(1);
|
||
}
|
||
mount(
|
||
decision,
|
||
h("div", { class: "card-head" }, h("h3", {}, t("pg.decision"))),
|
||
kv(t("pg.task"), c.task_label || "—"),
|
||
kv(t("pg.tier"), c.complexity?.tier ? h("span", { class: "badge tier-" + c.complexity.tier }, c.complexity.tier) : "—"),
|
||
h(
|
||
"div",
|
||
{ class: "kv", style: "display:block" },
|
||
h("div", { class: "flex" }, h("span", { class: "k grow" }, t("pg.score")), h("span", { class: "v" }, score.toFixed(2))),
|
||
h("div", { class: "scorebar" }, h("i", { style: `width:${Math.min(100, score * 100)}%` }))
|
||
),
|
||
kv(t("pg.selected"), h("span", { class: "mono" }, c.selected_model || "—")),
|
||
kv(t("pg.source"), c.route_source || "—"),
|
||
kv(t("pg.tokens"), haveUsage ? `${r.usage_estimated ? "≈ " : ""}${u.prompt_tokens ?? 0} / ${u.completion_tokens ?? 0}` : "—"),
|
||
kv(t("pg.tokSpeed"), tokS != null ? tokS + " tok/s" : "—"),
|
||
kv(t("pg.ttft"), r.ttft_ms != null ? ms(r.ttft_ms) : "—"),
|
||
kv(t("pg.cost"), money(c.cost_usd)),
|
||
kv(t("pg.latency"), ms(r.latency_ms)),
|
||
kv(t("pg.failover"), c.failover ? "yes" : "no")
|
||
);
|
||
}
|
||
|
||
const chatPanel = h(
|
||
"div",
|
||
{ class: "pg" },
|
||
h(
|
||
"div",
|
||
{},
|
||
h(
|
||
"div",
|
||
{ class: "card" },
|
||
field(t("pg.system"), sysIn),
|
||
field(t("pg.prompt"), promptIn),
|
||
field(t("pg.mode"), modeSeg),
|
||
modeExtra,
|
||
h("div", { class: "row" }, field(t("pg.temp"), tempIn), field(t("pg.maxTokens"), maxTokIn)),
|
||
h(
|
||
"div",
|
||
{ class: "flex", style: "justify-content:space-between; gap:8px" },
|
||
h("label", { class: "check", style: "margin:0" }, streamCb, t("pg.stream")),
|
||
h("div", { class: "flex", style: "gap:8px" }, clearBtn, sendBtn)
|
||
)
|
||
),
|
||
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.answer"))), out)
|
||
),
|
||
decision
|
||
);
|
||
|
||
// ═══════════════════════ shared media machinery ═══════════════════════
|
||
let mediaPoll = null;
|
||
function stopMediaPoll() {
|
||
if (mediaPoll) clearInterval(mediaPoll);
|
||
mediaPoll = null;
|
||
}
|
||
// one generation at a time server-side → one active job client-side
|
||
function watchJob(id, ui, onDone) {
|
||
stopMediaPoll();
|
||
const started = performance.now();
|
||
mediaPoll = setInterval(async () => {
|
||
if (!root.isConnected) return stopMediaPoll();
|
||
let j;
|
||
try {
|
||
j = await api.mediaJob(id);
|
||
} catch {
|
||
return;
|
||
}
|
||
const elapsed = (performance.now() - started) / 1000;
|
||
const pct = j.progress != null ? Math.round(j.progress * 100) : null;
|
||
// honest ETA once there is enough signal: total ≈ elapsed / progress
|
||
let eta = "";
|
||
if (j.state === "running" && j.progress > 0.03) {
|
||
const left = elapsed / j.progress - elapsed;
|
||
if (left > 1) eta = ` · ${t("pg.eta")} ~${left > 90 ? Math.round(left / 60) + " " + t("pg.min") : Math.round(left) + " s"}`;
|
||
}
|
||
let statusLine;
|
||
if (j.state === "queued") {
|
||
const pos = (j.queue_position ?? 0) + 1;
|
||
statusLine = t("pg.queued", { n: pos });
|
||
} else {
|
||
statusLine =
|
||
(j.phase || j.state) +
|
||
(pct != null && j.state === "running" ? ` · ${pct}%` : "") +
|
||
` · ${elapsed.toFixed(0)}s` + eta;
|
||
}
|
||
mount(
|
||
ui.bar,
|
||
h("div", { class: "pbar" },
|
||
j.state === "running"
|
||
? (pct != null
|
||
? h("div", { class: "pbar-fill det", style: `width:${Math.max(pct, 1)}%` })
|
||
: h("div", { class: "pbar-fill run" }))
|
||
: j.state === "queued"
|
||
? h("div", { class: "pbar-fill run" })
|
||
: h("div", { class: "pbar-fill " + (j.state === "done" ? "ok" : "err"), style: "width:100%" })
|
||
),
|
||
h("div", { class: "muted small" }, statusLine),
|
||
(j.log || []).length && j.state === "running" ? h("pre", { class: "conv-log", style: "max-height:90px" }, j.log.slice(-5).join("\n")) : null
|
||
);
|
||
if (j.state !== "running" && j.state !== "queued") {
|
||
stopMediaPoll();
|
||
ui.goBtn.disabled = false;
|
||
ui.cancelBtn.style.display = "none";
|
||
if (j.state === "done") onDone(j);
|
||
else if (j.state === "error") toast(t("pg.genFailed"), "err");
|
||
}
|
||
}, 1000);
|
||
}
|
||
function mediaControls(goBtn, onCancelId) {
|
||
const cancelBtn = h("button", { class: "btn ghost", style: "display:none" }, "✕ " + t("import.cancel"));
|
||
cancelBtn.addEventListener("click", async () => {
|
||
try {
|
||
await api.mediaCancel(onCancelId.id);
|
||
} catch (e) {
|
||
toast(e.message, "err");
|
||
}
|
||
});
|
||
return cancelBtn;
|
||
}
|
||
function noModelsHint(kindLabel) {
|
||
return h(
|
||
"div",
|
||
{ class: "card" },
|
||
h("div", { class: "callout" },
|
||
t("pg.noMediaModels", { kind: kindLabel }),
|
||
" ",
|
||
h("a", { href: "#/import" }, t("settings.cmf.importLink") + " ↗"))
|
||
);
|
||
}
|
||
function dice(seedIn) {
|
||
const b = h("button", { class: "btn ghost sm", title: t("pg.randomSeed") }, "🎲");
|
||
b.addEventListener("click", () => (seedIn.value = Math.floor(Math.random() * 1e9)));
|
||
return b;
|
||
}
|
||
|
||
// ── gallery: past generations of one kind, loaded lazily ──
|
||
function buildGallery(kind, onOpen) {
|
||
const host = h("div", {});
|
||
async function refresh() {
|
||
let jobs = [];
|
||
try {
|
||
jobs = ((await api.mediaJobs()).jobs || []).filter((j) => j.kind === kind && j.state === "done");
|
||
} catch {
|
||
return;
|
||
}
|
||
if (!jobs.length) {
|
||
mount(host);
|
||
return;
|
||
}
|
||
const grid = h("div", { class: "gal-grid" });
|
||
for (const j of jobs.slice(0, 12)) {
|
||
const del = h("button", { class: "gal-del", title: t("import.delete") }, "×");
|
||
del.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
if (!window.confirm(t("pg.galDeleteConfirm"))) return;
|
||
try {
|
||
await api.mediaDelete(j.id);
|
||
refresh();
|
||
} catch (err) {
|
||
toast(err.message, "err");
|
||
}
|
||
});
|
||
const cap = h("div", { class: "gal-cap" }, `${j.width}×${j.height} · seed ${j.seed}`);
|
||
const item = h("div", { class: "gal-item", title: j.prompt }, del, cap);
|
||
if (kind === "image") {
|
||
// lazy thumbnail: decode the PPM only when the item scrolls into view
|
||
const thumb = h("canvas", { class: "gal-thumb" });
|
||
item.prepend(thumb);
|
||
const io = new IntersectionObserver(async (entries) => {
|
||
if (!entries.some((x) => x.isIntersecting)) return;
|
||
io.disconnect();
|
||
try {
|
||
const buf = await mediaFileBytes(j.id, "image");
|
||
const full = ppmToCanvas(buf);
|
||
thumb.width = 128;
|
||
thumb.height = Math.round((128 * full.height) / full.width) || 128;
|
||
thumb.getContext("2d").drawImage(full, 0, 0, thumb.width, thumb.height);
|
||
} catch {
|
||
/* file gone */
|
||
}
|
||
});
|
||
io.observe(item);
|
||
} else {
|
||
item.prepend(h("div", { class: "gal-thumb gal-video" }, "🎬"));
|
||
}
|
||
item.addEventListener("click", () => onOpen(j));
|
||
grid.appendChild(item);
|
||
}
|
||
mount(host, h("div", { class: "card-title", style: "margin-top:14px" }, t("pg.gallery")), grid);
|
||
}
|
||
refresh();
|
||
return { host, refresh };
|
||
}
|
||
|
||
// ════════════════════════════ image panel ════════════════════════════
|
||
let imagePanel;
|
||
if (!imageModels.length) {
|
||
imagePanel = noModelsHint(t("pg.tab.image"));
|
||
} else {
|
||
const imModel = h("select", {}, ...imageModels.map((m) => h("option", { value: m.name }, `${m.name} · ${fmtBytes(m.size_bytes)}`)));
|
||
const imPrompt = h("textarea", { placeholder: t("pg.imagePromptPlaceholder"), rows: 3 });
|
||
const imW = h("select", {}, ...[256, 384, 512, 640, 768, 1024].map((v) => h("option", { value: v, selected: v === 512 ? true : null }, v)));
|
||
const imH = h("select", {}, ...[256, 384, 512, 640, 768, 1024].map((v) => h("option", { value: v, selected: v === 512 ? true : null }, v)));
|
||
const imSteps = h("input", { type: "number", min: 1, max: 100, value: 30 });
|
||
const imCfg = h("input", { type: "number", step: "0.5", value: 4 });
|
||
const imSeed = h("input", { type: "number", value: 42 });
|
||
const imBar = h("div", {});
|
||
const imOut = h("div", { class: "media-out" }, h("div", { class: "empty" }, t("pg.imageEmpty")));
|
||
const imMetrics = h("div", { class: "card routecard" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.genMetrics"))), h("div", { class: "empty" }, "—"));
|
||
const imGo = h("button", { class: "btn primary" }, "🖼 " + t("pg.generate"));
|
||
const imCancelRef = { id: null };
|
||
const imCancel = mediaControls(imGo, imCancelRef);
|
||
|
||
imGo.addEventListener("click", async () => {
|
||
const prompt = imPrompt.value.trim();
|
||
if (!prompt) return;
|
||
imGo.disabled = true;
|
||
imCancel.style.display = "";
|
||
mount(imOut, h("div", { class: "flex" }, h("span", { class: "spinner" }), " ", t("pg.generating")));
|
||
const params = {
|
||
kind: "image",
|
||
model: imModel.value,
|
||
prompt,
|
||
width: +imW.value,
|
||
height: +imH.value,
|
||
steps: +imSteps.value || 30,
|
||
cfg: parseFloat(imCfg.value) || 4,
|
||
seed: +imSeed.value || 42,
|
||
};
|
||
try {
|
||
const r = await api.mediaGenerate(params);
|
||
imCancelRef.id = r.job;
|
||
const t0 = performance.now();
|
||
watchJob(r.job, { bar: imBar, goBtn: imGo, cancelBtn: imCancel }, async (j) => {
|
||
mount(imBar);
|
||
const secs = (performance.now() - t0) / 1000;
|
||
await showImageResult(j, secs);
|
||
imGallery.refresh();
|
||
});
|
||
} catch (e) {
|
||
imGo.disabled = false;
|
||
imCancel.style.display = "none";
|
||
mount(imOut, h("div", { class: "callout" }, String(e.message || e)));
|
||
}
|
||
});
|
||
|
||
async function showImageResult(j, secs) {
|
||
try {
|
||
const buf = await mediaFileBytes(j.id, "image");
|
||
const canvas = ppmToCanvas(buf);
|
||
canvas.className = "media-canvas";
|
||
const dl = h("button", { class: "btn sm" }, "⬇ PNG");
|
||
dl.addEventListener("click", () =>
|
||
canvas.toBlob((b) => {
|
||
const a = h("a", { href: URL.createObjectURL(b), download: `cortiq-${j.id}.png` });
|
||
a.click();
|
||
}, "image/png")
|
||
);
|
||
mount(imOut, canvas, h("div", { class: "actions" }, dl));
|
||
const rows = [
|
||
kv(t("pg.model"), h("span", { class: "mono" }, j.model)),
|
||
kv(t("pg.resolution"), `${j.width}×${j.height}`),
|
||
kv(t("pg.steps"), String(j.steps)),
|
||
kv(t("pg.seed"), String(j.seed)),
|
||
kv(t("pg.fileSize"), fmtBytes(j.out_bytes)),
|
||
];
|
||
if (secs != null) {
|
||
rows.push(kv(t("pg.genTime"), secs.toFixed(1) + " s"));
|
||
rows.push(kv(t("pg.timePerStep"), (secs / j.steps).toFixed(2) + " s"));
|
||
} else if (j.finished && j.started) {
|
||
const dur = j.finished - j.started;
|
||
rows.push(kv(t("pg.genTime"), dur + " s"));
|
||
rows.push(kv(t("pg.timePerStep"), (dur / j.steps).toFixed(2) + " s"));
|
||
}
|
||
mount(imMetrics, h("div", { class: "card-head" }, h("h3", {}, t("pg.genMetrics"))), ...rows);
|
||
} catch (e) {
|
||
mount(imOut, h("div", { class: "callout" }, String(e.message || e)));
|
||
}
|
||
}
|
||
const imGallery = buildGallery("image", (j) => {
|
||
imSeed.value = j.seed;
|
||
imSteps.value = j.steps;
|
||
showImageResult(j, null);
|
||
});
|
||
|
||
imagePanel = h(
|
||
"div",
|
||
{ class: "pg" },
|
||
h(
|
||
"div",
|
||
{},
|
||
h(
|
||
"div",
|
||
{ class: "card" },
|
||
field(t("pg.model"), imModel),
|
||
field(t("pg.prompt"), imPrompt),
|
||
h("div", { class: "row" }, field(t("pg.width"), imW), field(t("pg.height"), imH)),
|
||
h("div", { class: "row" },
|
||
field(t("pg.steps"), imSteps),
|
||
field("CFG", imCfg, t("pg.cfgHint"))),
|
||
h("div", { class: "row" },
|
||
field(t("pg.seed"), h("div", { class: "flex", style: "gap:6px" }, imSeed, dice(imSeed)))),
|
||
h("div", { class: "actions" }, imCancel, imGo),
|
||
imBar
|
||
),
|
||
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), imOut, imGallery.host)
|
||
),
|
||
imMetrics
|
||
);
|
||
}
|
||
|
||
// ════════════════════════════ video panel ════════════════════════════
|
||
let videoPanel;
|
||
if (!videoModels.length) {
|
||
videoPanel = noModelsHint(t("pg.tab.video"));
|
||
} else {
|
||
const vModel = h("select", {}, ...videoModels.map((m) => h("option", { value: m.name }, `${m.name} · ${fmtBytes(m.size_bytes)}`)));
|
||
const vPrompt = h("textarea", { placeholder: t("pg.videoPromptPlaceholder"), rows: 3 });
|
||
const vW = h("input", { type: "number", step: 32, value: 512 });
|
||
const vH = h("input", { type: "number", step: 32, value: 288 });
|
||
const vFrames = h("input", { type: "number", value: 39 });
|
||
const vSteps = h("input", { type: "number", value: 4 });
|
||
const vSeed = h("input", { type: "number", value: 42 });
|
||
const vQuality = h("input", { type: "number", min: 10, max: 100, value: 92 });
|
||
const vFirst = h("input", { type: "file", accept: "image/*" });
|
||
const vLast = h("input", { type: "file", accept: "image/*" });
|
||
const vBar = h("div", {});
|
||
const vOut = h("div", { class: "media-out" }, h("div", { class: "empty" }, t("pg.videoEmpty")));
|
||
const vMetrics = h("div", { class: "card routecard" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.genMetrics"))), h("div", { class: "empty" }, "—"));
|
||
const vGo = h("button", { class: "btn primary" }, "🎬 " + t("pg.generate"));
|
||
const vCancelRef = { id: null };
|
||
const vCancel = mediaControls(vGo, vCancelRef);
|
||
|
||
vGo.addEventListener("click", async () => {
|
||
const prompt = vPrompt.value.trim();
|
||
if (!prompt) return;
|
||
vGo.disabled = true;
|
||
vCancel.style.display = "";
|
||
mount(vOut, h("div", { class: "flex" }, h("span", { class: "spinner" }), " ", t("pg.generating")));
|
||
const params = {
|
||
kind: "video",
|
||
model: vModel.value,
|
||
prompt,
|
||
width: +vW.value || 512,
|
||
height: +vH.value || 288,
|
||
frames: +vFrames.value || 39,
|
||
steps: +vSteps.value || 4,
|
||
seed: +vSeed.value || 42,
|
||
quality: +vQuality.value || 92,
|
||
};
|
||
try {
|
||
if (vFirst.files[0]) params.first_frame_b64 = await fileToPpmB64(vFirst.files[0], params.width, params.height);
|
||
if (vLast.files[0]) params.last_frame_b64 = await fileToPpmB64(vLast.files[0], params.width, params.height);
|
||
const r = await api.mediaGenerate(params);
|
||
vCancelRef.id = r.job;
|
||
const t0 = performance.now();
|
||
watchJob(r.job, { bar: vBar, goBtn: vGo, cancelBtn: vCancel }, async (j) => {
|
||
mount(vBar);
|
||
vGallery.refresh();
|
||
const secs = (performance.now() - t0) / 1000;
|
||
try {
|
||
await showVideoResult(j, vOut);
|
||
const durS = (j.frames || 0) / 24;
|
||
mount(
|
||
vMetrics,
|
||
h("div", { class: "card-head" }, h("h3", {}, t("pg.genMetrics"))),
|
||
kv(t("pg.model"), h("span", { class: "mono" }, j.model)),
|
||
kv(t("pg.resolution"), `${j.width}×${j.height}`),
|
||
kv(t("pg.frames"), `${j.frames} @ 24 fps`),
|
||
kv(t("pg.duration"), durS.toFixed(1) + " s"),
|
||
kv(t("pg.steps"), String(j.steps)),
|
||
kv(t("pg.seed"), String(j.seed)),
|
||
kv(t("pg.genTime"), secs.toFixed(1) + " s"),
|
||
kv(t("pg.timePerFrame"), j.frames ? (secs / j.frames).toFixed(2) + " s" : "—"),
|
||
kv(t("pg.fileSize"), fmtBytes(j.out_bytes))
|
||
);
|
||
} catch (e) {
|
||
mount(vOut, h("div", { class: "callout" }, String(e.message || e)));
|
||
}
|
||
});
|
||
} catch (e) {
|
||
vGo.disabled = false;
|
||
vCancel.style.display = "none";
|
||
mount(vOut, h("div", { class: "callout" }, String(e.message || e)));
|
||
}
|
||
});
|
||
|
||
async function showVideoResult(j, host) {
|
||
const downloads = h("div", { class: "actions" });
|
||
for (const kind of j.outputs || []) {
|
||
const label = { video: "⬇ AVI", audio: "⬇ WAV", mp4: "⬇ MP4" }[kind];
|
||
if (!label) continue;
|
||
const b = h("button", { class: "btn sm" }, label);
|
||
b.addEventListener("click", async () => {
|
||
const { url } = await mediaFileUrl(j.id, kind);
|
||
const ext = kind === "video" ? "avi" : kind === "audio" ? "wav" : "mp4";
|
||
h("a", { href: url, download: `cortiq-${j.id}.${ext}` }).click();
|
||
});
|
||
downloads.appendChild(b);
|
||
}
|
||
if ((j.outputs || []).includes("mp4")) {
|
||
// ffmpeg was present server-side → native playback
|
||
const { url } = await mediaFileUrl(j.id, "mp4");
|
||
mount(host, h("video", { src: url, controls: true, class: "media-canvas" }), downloads);
|
||
return;
|
||
}
|
||
// no ffmpeg anywhere: decode the MJPEG AVI ourselves, sync to the wav
|
||
const buf = await mediaFileBytes(j.id, "video");
|
||
const frames = aviJpegFrames(buf);
|
||
let audio = null;
|
||
if ((j.outputs || []).includes("audio")) {
|
||
const { url } = await mediaFileUrl(j.id, "audio");
|
||
audio = h("audio", { src: url });
|
||
}
|
||
const player = mjpegPlayer(frames, 24, audio);
|
||
player.canvas.className = "media-canvas";
|
||
await player.seek0();
|
||
const playBtn = h("button", { class: "btn primary sm" }, "▶ " + t("pg.play"));
|
||
playBtn.addEventListener("click", async () => {
|
||
if (player.state.playing) {
|
||
player.pause();
|
||
playBtn.textContent = "▶ " + t("pg.play");
|
||
} else {
|
||
playBtn.textContent = "⏸";
|
||
await player.play();
|
||
}
|
||
});
|
||
mount(host, player.canvas, h("div", { class: "actions" }, playBtn, ...downloads.childNodes ? [downloads] : []));
|
||
}
|
||
|
||
const vGallery = buildGallery("video", (j) => showVideoResult(j, vOut));
|
||
|
||
videoPanel = h(
|
||
"div",
|
||
{ class: "pg" },
|
||
h(
|
||
"div",
|
||
{},
|
||
h(
|
||
"div",
|
||
{ class: "card" },
|
||
field(t("pg.model"), vModel),
|
||
field(t("pg.prompt"), vPrompt),
|
||
h("div", { class: "row" }, field(t("pg.width"), vW, t("pg.videoSizeHint")), field(t("pg.height"), vH)),
|
||
h("div", { class: "row" }, field(t("pg.frames"), vFrames, t("pg.framesHint")), field(t("pg.steps"), vSteps, t("pg.videoStepsHint"))),
|
||
h("div", { class: "row" },
|
||
field(t("pg.seed"), h("div", { class: "flex", style: "gap:6px" }, vSeed, dice(vSeed))),
|
||
field(t("pg.quality"), vQuality)),
|
||
h("div", { class: "row" }, field(t("pg.firstFrame"), vFirst, t("pg.keyframeHint")), field(t("pg.lastFrame"), vLast)),
|
||
h("div", { class: "actions" }, vCancel, vGo),
|
||
vBar
|
||
),
|
||
h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("pg.result"))), vOut, vGallery.host)
|
||
),
|
||
vMetrics
|
||
);
|
||
}
|
||
|
||
const panels = { chat: chatPanel, image: imagePanel, video: videoPanel };
|
||
|
||
mount(
|
||
root,
|
||
h("div", { class: "page-head" }, h("h2", {}, t("pg.title")), h("p", {}, t("pg.subtitle"))),
|
||
tabsSeg,
|
||
panelHost
|
||
);
|
||
mount(panelHost, panels.chat);
|
||
return root;
|
||
}
|