// 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 out = h("div", { class: "answer" }, 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" }, "—")); 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({ 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"); mount(out, h("div", { class: "flex" }, h("span", { class: "spinner" }), " ", t("pg.sending"))); const body = { model, messages, temperature: parseFloat(tempIn.value), max_tokens: parseInt(maxTokIn.value) || null, }; try { if (streamCb.checked) { const started = performance.now(); let acc = ""; out.textContent = ""; const info = await testStream(body, (d) => { acc += d; out.textContent = acc; }); out.textContent = acc || "—"; 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) { mount(out, r.answer || h("span", { class: "muted" }, "—")); renderDecision(r); } else { mount(out, 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) { mount(out, h("div", { class: "callout" }, String(e.message || e))); } sendBtn.disabled = false; sendBtn.textContent = t("pg.send"); }); 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" }, h("label", { class: "check", style: "margin:0" }, streamCb, t("pg.stream")), 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 secs = ((performance.now() - started) / 1000).toFixed(0); const pct = j.progress != null ? Math.round(j.progress * 100) : null; 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" })) : h("div", { class: "pbar-fill " + (j.state === "done" ? "ok" : "err"), style: "width:100%" }) ), h("div", { class: "muted small" }, (j.phase || j.state) + (pct != null && j.state === "running" ? ` · ${pct}%` : "") + ` · ${secs}s`), (j.log || []).length ? h("pre", { class: "conv-log", style: "max-height:90px" }, j.log.slice(-5).join("\n")) : null ); if (j.state !== "running") { 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; } // ════════════════════════════ 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; 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)); mount( imMetrics, 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.steps"), String(j.steps)), kv("CFG", String(params.cfg)), kv(t("pg.seed"), String(j.seed)), kv(t("pg.genTime"), secs.toFixed(1) + " s"), kv(t("pg.timePerStep"), (secs / j.steps).toFixed(2) + " s"), kv(t("pg.fileSize"), fmtBytes(j.out_bytes)) ); } catch (e) { mount(imOut, h("div", { class: "callout" }, String(e.message || e))); } }); } catch (e) { imGo.disabled = false; imCancel.style.display = "none"; mount(imOut, h("div", { class: "callout" }, String(e.message || e))); } }); 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) ), 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); 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] : [])); } 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) ), 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; }