Files
cortiq-gateway/web/js/views/playground.js
T
infosave2007 15de36be3b feat: ops & UX batch — jobs survive restarts, logs/system in the console, media queue, gallery, chat, exact stream tokens
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.
2026-08-07 17:07:48 +03:00

673 lines
27 KiB
JavaScript
Vendored
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.
// 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;
}