feat(playground): image & video generation tabs + real chat metrics

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.
This commit is contained in:
infosave2007
2026-08-07 15:55:06 +03:00
parent 32ce2f7ed7
commit a77beeabab
16 changed files with 1814 additions and 30 deletions
+47 -2
View File
@@ -118,6 +118,14 @@ export const api = {
registerImport: (job) =>
j("POST", "/import/" + encodeURIComponent(job) + "/register"),
// media generation (cortiq imagine / animate over local .cmf)
mediaModels: () => j("GET", "/media/models"),
mediaGenerate: (b) => j("POST", "/media", b),
mediaJobs: () => j("GET", "/media"),
mediaJob: (id) => j("GET", "/media/" + encodeURIComponent(id)),
mediaCancel: (id) => j("POST", "/media/" + encodeURIComponent(id) + "/cancel"),
mediaDelete: (id) => j("DELETE", "/media/" + encodeURIComponent(id)),
// first-run onboarding
setupStatus: () => j("GET", "/setup/status"),
// Connects every finished download as a local model unless register === false.
@@ -155,6 +163,10 @@ export async function testStream(body, onDelta) {
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = "";
const t0 = performance.now();
let ttftMs = null; // time to first content token
let usage = null; // OpenAI-style final-chunk usage, when the provider sends it
let usageEstimated = false; // gateway-injected estimate rather than tokenizer truth
for (;;) {
const { done, value } = await reader.read();
if (done) break;
@@ -167,8 +179,16 @@ export async function testStream(body, onDelta) {
const data = line.slice(5).trim();
if (!data || data === "[DONE]") continue;
try {
const d = JSON.parse(data).choices?.[0]?.delta?.content;
if (d) onDelta(d);
const chunk = JSON.parse(data);
if (chunk.usage) {
usage = chunk.usage;
usageEstimated = !!chunk.cortiq_estimated;
}
const d = chunk.choices?.[0]?.delta?.content;
if (d) {
if (ttftMs == null) ttftMs = Math.round(performance.now() - t0);
onDelta(d);
}
} catch {
/* ignore malformed chunk */
}
@@ -182,5 +202,30 @@ export async function testStream(body, onDelta) {
selected_model: h.get("x-cortiq-selected-model") || "",
route_source: h.get("x-cortiq-route-source") || "",
cost_usd: parseFloat(h.get("x-cortiq-cost-usd") || "0"),
usage,
usage_estimated: usageEstimated,
ttft_ms: ttftMs,
};
}
// Fetch a media job's output with the admin token and hand back an object URL
// (plain <img src>/<video src> cannot carry the Authorization header).
export async function mediaFileUrl(job, kind) {
const tok = getToken();
const headers = {};
if (tok) headers["Authorization"] = "Bearer " + tok;
const r = await fetch(`${BASE}/media/${encodeURIComponent(job)}/file/${encodeURIComponent(kind)}`, { headers });
if (!r.ok) throw new Error((await r.text()) || r.statusText);
return { url: URL.createObjectURL(await r.blob()), size: +(r.headers.get("content-length") || 0) };
}
// Same fetch, raw bytes — for outputs the browser cannot display natively
// (P6 PPM, MJPEG AVI) that we decode in JS.
export async function mediaFileBytes(job, kind) {
const tok = getToken();
const headers = {};
if (tok) headers["Authorization"] = "Bearer " + tok;
const r = await fetch(`${BASE}/media/${encodeURIComponent(job)}/file/${encodeURIComponent(kind)}`, { headers });
if (!r.ok) throw new Error((await r.text()) || r.statusText);
return r.arrayBuffer();
}