The infosave collection now ships media models (Lumina-Image-2.0, MiniMax-H3 Turbo) that cortiq runs via `imagine`/`animate` CLI, not `cortiq serve`. The gateway grows a media API: local .cmf files are classified by `cortiq info` (chat/image/video, cached), generations run as tracked jobs with step progress parsed from stdout, one at a time, cancellable, outputs under <data>/media. The playground becomes three tabs. Image: width/height/steps/CFG/seed, live progress, result rendered from P6 PPM to a canvas in the browser (PNG export via toBlob). Video: frames/steps/quality/seed plus optional first/last keyframes (rasterized to PPM client-side); the MJPEG+PCM AVI is decoded in JS and played on a canvas synced to the wav — no ffmpeg on either side (mp4 remux happens opportunistically when ffmpeg exists). A metrics card shows resolution, steps, seed, total time, time per step/frame and file size. Chat metrics: the local cortiq server sends no usage chunk in streams, so token counts showed 0/0. tap_stream now injects one OpenAI-shaped usage event ahead of [DONE] with the gateway's ~4-chars/token estimate, flagged `cortiq_estimated`; the UI renders it as "≈ N / M" and adds tok/s and time-to-first-token. Registration skips media packs (a `cortiq serve` entry for a diffusion model would only fail) in both Register and the wizard's Finish. 36 new UI strings translated across all 7 languages.
547 lines
23 KiB
JavaScript
Vendored
547 lines
23 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 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;
|
||
}
|