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
+111
View File
@@ -184,6 +184,14 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
.route("/admin/api/requests", get(get_requests))
.route("/admin/api/test", post(run_test))
.route("/admin/api/test/stream", post(run_test_stream))
.route("/admin/api/media/models", get(media_models))
.route("/admin/api/media", get(media_jobs).post(media_generate))
.route(
"/admin/api/media/:job",
get(media_job_status).delete(media_delete),
)
.route("/admin/api/media/:job/cancel", post(media_cancel))
.route("/admin/api/media/:job/file/:kind", get(media_file))
.route("/admin/api/setup/status", get(setup_status))
.route("/admin/api/setup/complete", post(setup_complete))
.route("/admin/api/setup/featured", get(setup_featured))
@@ -1420,8 +1428,16 @@ async fn setup_complete(
.map(|j| j.output)
.collect();
if !finished.is_empty() {
let cortiq_bin = state.live().cfg.cmf.cortiq_bin.clone();
let mut cfg = current_cfg(&state);
for output in &finished {
// media packs (image/video) are used by the media API directly,
// not served — connecting them as chat servers would only fail
let modality =
crate::media::classify_file(std::path::Path::new(output), &cortiq_bin).await;
if modality != crate::media::Modality::Chat {
continue;
}
if let Some(id) = register_cmf_output(&mut cfg, output) {
registered.push(id);
}
@@ -1972,6 +1988,15 @@ async fn register_import(State(state): State<SharedState>, Path(job): Path<Strin
if j.state != "done" {
return Err(ApiError::bad("conversion not finished"));
}
let live = state.live();
// Image/video packs are not served by `cortiq serve` — they are used
// directly by the media API. Nothing to register; tell the UI what it is.
let modality =
crate::media::classify_file(std::path::Path::new(&j.output), &live.cfg.cmf.cortiq_bin)
.await;
if modality != crate::media::Modality::Chat {
return ok(json!({ "ok": true, "media_kind": modality, "model_id": null }));
}
let mut cfg = current_cfg(&state);
let id = register_cmf_output(&mut cfg, &j.output).ok_or_else(|| {
ApiError::bad(format!(
@@ -2004,6 +2029,92 @@ async fn get_requests(State(state): State<SharedState>, Query(q): Query<RecentQu
ok(json!({ "requests": items }))
}
// ───────────────────────────── media generation ─────────────────────────────
/// Local `.cmf` files with their modality (chat / image / video) — the
/// playground's media tabs read this to know what can generate what.
async fn media_models(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
let models =
crate::media::list_media_models(&live.cfg.cmf.models_dir, &live.cfg.cmf.cortiq_bin).await;
ok(json!({ "models": models }))
}
async fn media_jobs(State(state): State<SharedState>) -> ApiResult {
ok(json!({ "jobs": state.media.list() }))
}
async fn media_generate(
State(state): State<SharedState>,
Json(p): Json<crate::media::MediaParams>,
) -> ApiResult {
let live = state.live();
let id = crate::media::start_generation(state.media.clone(), &live.cfg.cmf, p)
.map_err(ApiError::bad)?;
ok(json!({ "job": id }))
}
async fn media_job_status(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
match state.media.get(&job) {
Some(j) => ok(serde_json::to_value(j).unwrap_or_else(|_| json!({}))),
None => Err(ApiError::not_found(format!("media job '{job}' not found"))),
}
}
async fn media_cancel(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
if state.media.get(&job).is_none() {
return Err(ApiError::not_found(format!("media job '{job}' not found")));
}
ok(json!({ "ok": state.media.cancel(&job) }))
}
async fn media_delete(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
if state.media.get(&job).is_none() {
return Err(ApiError::not_found(format!("media job '{job}' not found")));
}
let removed = state
.media
.delete(&job, &crate::media::media_dir())
.map_err(ApiError::bad)?;
ok(json!({ "ok": removed }))
}
/// Serve a finished job's output bytes (ppm / avi / wav / mp4). The job id is
/// server-generated hex and the file name is fixed per kind, so no path from the
/// client ever reaches the filesystem.
async fn media_file(
State(state): State<SharedState>,
Path((job, kind)): Path<(String, String)>,
) -> Response {
let Some(j) = state.media.get(&job) else {
return ApiError::not_found(format!("media job '{job}' not found")).into_response();
};
if j.state != "done" {
return ApiError::bad("generation not finished").into_response();
}
let Some((file, mime)) = crate::media::output_file(&kind) else {
return ApiError::bad(format!("unknown output kind '{kind}'")).into_response();
};
let path = crate::media::media_dir().join(&job).join(file);
match tokio::fs::read(&path).await {
Ok(bytes) => (
[
(header::CONTENT_TYPE, mime.to_string()),
(
header::CONTENT_DISPOSITION,
format!(
"inline; filename=\"cortiq-{job}.{}\"",
file.rsplit('.').next().unwrap_or("bin")
),
),
],
bytes,
)
.into_response(),
Err(_) => ApiError::not_found("output file missing on disk").into_response(),
}
}
// ───────────────────────────── playground (test) ───────────────────────────
#[derive(Deserialize)]