From 99f421afb2de44b0bc2665dcf336134ace270391 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 9 Nov 2026 06:48:24 +0000 Subject: [PATCH] feat(chat): show /music cover art and title in c0mpute.com/chat Infernet now draws album cover art for every /music song in parallel with it. Proxy GET /api/v1/music//cover, pass the title and cover status through the poll, and show the cover (with download) above the player as soon as it is ready. Co-Authored-By: Claude Opus 5.5 --- .../src/app/api/v1/music/[id]/cover/route.ts | 24 +++++++++++++++++++ apps/web/src/app/api/v1/music/[id]/route.ts | 2 +- apps/web/src/app/chat/chat-app.tsx | 16 ++++++++++--- 3 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/api/v1/music/[id]/cover/route.ts diff --git a/apps/web/src/app/api/v1/music/[id]/cover/route.ts b/apps/web/src/app/api/v1/music/[id]/cover/route.ts new file mode 100644 index 00000000..9c55ebab --- /dev/null +++ b/apps/web/src/app/api/v1/music/[id]/cover/route.ts @@ -0,0 +1,24 @@ +import { isJobId } from "@/lib/chat/images"; +import { currentUser, infernet, json, unauthorized } from "@/lib/chat/server"; + +export const dynamic = "force-dynamic"; + +/** GET /api/v1/music//cover: the song's cover art (image/png) once ready. */ +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + const user = await currentUser(req); + if (!user) return unauthorized(); + const { id } = await params; + if (!isJobId(id)) return json({ error: { message: "unknown music job", code: 404 } }, 404); + let upstream: Response; + try { + upstream = await infernet(`/api/chat/music/${id}/cover`, { signal: AbortSignal.timeout(20_000) }); + } catch (e) { + return json({ error: { message: `network unreachable: ${e instanceof Error ? e.message : e}`, code: 502 } }, 502); + } + const type = upstream.headers.get("content-type") ?? ""; + if (upstream.status === 200 && type.startsWith("image/png")) { + return new Response(upstream.body, { status: 200, headers: { "content-type": "image/png", "cache-control": "private, max-age=3600" } }); + } + await upstream.body?.cancel().catch(() => {}); + return json({ error: { message: "no cover art for this song (yet)", code: 404 } }, 404); +} diff --git a/apps/web/src/app/api/v1/music/[id]/route.ts b/apps/web/src/app/api/v1/music/[id]/route.ts index f8d1857c..30da8fd7 100644 --- a/apps/web/src/app/api/v1/music/[id]/route.ts +++ b/apps/web/src/app/api/v1/music/[id]/route.ts @@ -33,7 +33,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri // Infernet writes lyrics from a brief first; pass what it wrote along. const pick = (k: string) => (typeof body[k] === "string" || typeof body[k] === "number" ? body[k] : undefined); return json( - { id, status: "pending", seconds: body.seconds ?? null, stage: pick("stage"), caption: pick("caption"), lyrics: pick("lyrics"), duration: pick("duration"), node: sanitizeNodeProgress(body.node) }, + { id, status: "pending", seconds: body.seconds ?? null, stage: pick("stage"), caption: pick("caption"), lyrics: pick("lyrics"), duration: pick("duration"), title: pick("title"), cover: pick("cover"), node: sanitizeNodeProgress(body.node) }, 202, { "cache-control": "no-store" }, ); diff --git a/apps/web/src/app/chat/chat-app.tsx b/apps/web/src/app/chat/chat-app.tsx index 5615ff39..9184584b 100644 --- a/apps/web/src/app/chat/chat-app.tsx +++ b/apps/web/src/app/chat/chat-app.tsx @@ -268,7 +268,8 @@ export function ChatApp({ error }: { error: string | null }) { const start = await res.json().catch(() => ({})); if (res.status !== 202 || !start.poll) throw new Error(start.error?.message ?? `HTTP ${res.status}`); // Infernet may write lyrics from the brief first; keep what it wrote. - let written: { caption: string; lyrics: string } | null = null; + let written: { caption: string; lyrics: string; title?: string | null } | null = null; + let coverUrl: string | null = null; const deadline = Date.now() + 45 * 60_000; let done: Response | null = null; while (!done) { @@ -279,7 +280,15 @@ export function ChatApp({ error }: { error: string | null }) { if (poll.status === 202) { const p = await poll.json().catch(() => ({})); const secs = Math.round((Date.now() - started) / 1000); - if (typeof p.caption === "string") written = { caption: p.caption, lyrics: typeof p.lyrics === "string" ? p.lyrics : "" }; + if (typeof p.caption === "string") written = { caption: p.caption, lyrics: typeof p.lyrics === "string" ? p.lyrics : "", title: typeof p.title === "string" ? p.title : null }; + // Cover art is drawn in parallel and usually lands before the song. + if (p.cover === "ready" && !coverUrl) { + const c = await fetch(`${start.poll}/cover`, { cache: "no-store", signal: ctrl.signal }).catch(() => null); + if (c?.ok) { + coverUrl = URL.createObjectURL(await c.blob()); + setLast({ image: coverUrl }); + } + } const node = sanitizeNodeProgress(p.node); const head = p.stage === "writing" @@ -299,7 +308,8 @@ export function ChatApp({ error }: { error: string | null }) { const blob = await done.blob(); const url = URL.createObjectURL(blob); setLast({ - content: `[music: ${(written?.caption ?? music.prompt).slice(0, 300)}${written?.lyrics || music.lyrics ? " (with lyrics)" : ", instrumental"}] (${Math.round((Date.now() - started) / 1000)}s on a c0mpute worker)${written?.lyrics ? `\n\n${written.lyrics}` : ""}`, + ...(coverUrl ? { image: coverUrl } : {}), + content: `${written?.title ? `"${written.title}" ` : ""}[music: ${(written?.caption ?? music.prompt).slice(0, 300)}${written?.lyrics || music.lyrics ? " (with lyrics)" : ", instrumental"}] (${Math.round((Date.now() - started) / 1000)}s on a c0mpute worker)${written?.lyrics ? `\n\n${written.lyrics}` : ""}`, audio: url, audioType: blob.type || "audio/mpeg", });