low/medium/high leaked as raw English into the bands legend, the tier badges on the Routing page, the pair complexity selects, the benchmark's tier column and the Playground decision panel. New trTier() helper renders the human name (ru: лёгкий/средний/сложный, de: leicht/mittel/schwer, fr/es/zh/tr likewise); custom tier names pass through untouched, badge colors stay keyed by the raw tier. Verified in the browser in Russian and German.
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, moneyEl, ms } from "../ui.js";
|
||
import { t, trTier } 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 }, trTier(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"), moneyEl(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;
|
||
}
|