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:
Vendored
+47
-2
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user