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:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user