feat(web): show media jobs as a live card in the conversation
Media jobs report progress and results through the media block, which the web app did not render. - MediaCard renders the block: operation icon, title, status (reusing the task card's labels, plus "Waiting for GPU" for jobs queued behind the GPU), a progress bar while active, the error when failed, and outputs: images as thumbnails with the existing lightbox, video and audio in the media player, subtitle and text files as file cards. Finished video and audio get a download link, "Export" with presets labelled by aspect ratio (the video's own shape left out) and "Transcribe", offered only when the space can run them; running jobs get "Cancel". - MessageView renders it with one branch. - An E2E seeds the card on a thread snapshot with real uploaded artifacts. - artifact-url.ts and MediaPlayer.tsx are minimal stand-ins for the pipeline workstream's files and give way to them on merge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
parent
5e0b09d9c1
commit
8b2dcaa767
164
apps/web/e2e/media-card.spec.ts
Normal file
164
apps/web/e2e/media-card.spec.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import {
|
||||
activeBotId,
|
||||
captureScreenshot,
|
||||
completeOnboarding,
|
||||
fetchRpcEnvelope,
|
||||
fulfillFetchedRpc,
|
||||
rpc,
|
||||
signup,
|
||||
} from "./helpers";
|
||||
|
||||
type MediaBlock = Record<string, unknown> & { kind: "media" };
|
||||
type Snapshot = {
|
||||
threadId: string;
|
||||
messages: Array<{ seq: number } & Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** One second of a quiet 440 Hz tone as 8 kHz mono 16-bit WAV, small enough to inline. */
|
||||
function toneWavBase64(): string {
|
||||
const rate = 8000;
|
||||
const samples = rate;
|
||||
const buffer = Buffer.alloc(44 + samples * 2);
|
||||
buffer.write("RIFF", 0);
|
||||
buffer.writeUInt32LE(36 + samples * 2, 4);
|
||||
buffer.write("WAVEfmt ", 8);
|
||||
buffer.writeUInt32LE(16, 16);
|
||||
buffer.writeUInt16LE(1, 20);
|
||||
buffer.writeUInt16LE(1, 22);
|
||||
buffer.writeUInt32LE(rate, 24);
|
||||
buffer.writeUInt32LE(rate * 2, 28);
|
||||
buffer.writeUInt16LE(2, 32);
|
||||
buffer.writeUInt16LE(16, 34);
|
||||
buffer.write("data", 36);
|
||||
buffer.writeUInt32LE(samples * 2, 40);
|
||||
for (let index = 0; index < samples; index += 1) {
|
||||
buffer.writeInt16LE(
|
||||
Math.round(Math.sin((2 * Math.PI * 440 * index) / rate) * 3000),
|
||||
44 + index * 2,
|
||||
);
|
||||
}
|
||||
return buffer.toString("base64");
|
||||
}
|
||||
|
||||
/** A small grey gradient still, drawn by the browser so no binary fixture is needed. */
|
||||
async function stillPngBase64(page: Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 320;
|
||||
canvas.height = 180;
|
||||
const context = canvas.getContext("2d")!;
|
||||
const gradient = context.createLinearGradient(0, 0, 320, 180);
|
||||
gradient.addColorStop(0, "rgb(40, 40, 40)");
|
||||
gradient.addColorStop(1, "rgb(200, 200, 200)");
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, 320, 180);
|
||||
return canvas.toDataURL("image/png").split(",")[1]!;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Media jobs run on a GPU server or ffmpeg that CI does not have, so the card is seeded: real
|
||||
* artifacts are uploaded, and the thread snapshot carries a bot message with the job's card.
|
||||
*/
|
||||
async function seedMediaMessage(page: Page, botId: string, block: () => MediaBlock) {
|
||||
await page.route("**/rpc/threads/get", async (route) => {
|
||||
const fetched = await fetchRpcEnvelope(route);
|
||||
const snapshot = fetched.body.json as (Snapshot & { botId?: string }) | undefined;
|
||||
if (snapshot?.botId === botId) {
|
||||
const seq = Math.max(0, ...snapshot.messages.map((message) => message.seq)) + 1;
|
||||
snapshot.messages.push({
|
||||
id: `media-message-${seq}`,
|
||||
threadId: snapshot.threadId,
|
||||
seq,
|
||||
role: "bot",
|
||||
botId,
|
||||
blocks: [block()],
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
await fulfillFetchedRpc(route, fetched);
|
||||
});
|
||||
}
|
||||
|
||||
test("renders a media job card while it runs and when it is done", async ({ page }, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `media-card-${stamp}@rakazo.test`, "password12", "Media Card");
|
||||
await completeOnboarding(page);
|
||||
const botId = activeBotId(page);
|
||||
|
||||
const still = await rpc<{ id: string; size: number }>(page, "artifacts/create", {
|
||||
botId,
|
||||
name: "still.png",
|
||||
mimeType: "image/png",
|
||||
contentBase64: await stillPngBase64(page),
|
||||
});
|
||||
const voice = await rpc<{ id: string; size: number }>(page, "artifacts/create", {
|
||||
botId,
|
||||
name: "voice-note.wav",
|
||||
mimeType: "audio/wav",
|
||||
contentBase64: toneWavBase64(),
|
||||
});
|
||||
|
||||
let state: "running" | "done" = "running";
|
||||
await seedMediaMessage(page, botId, () =>
|
||||
state === "running"
|
||||
? {
|
||||
kind: "media",
|
||||
jobId: "media-job-1",
|
||||
operation: "edit_video",
|
||||
status: "running",
|
||||
title: "Cut the pauses and reframe for 9:16",
|
||||
progress: 0.4,
|
||||
outputs: [],
|
||||
}
|
||||
: {
|
||||
kind: "media",
|
||||
jobId: "media-job-1",
|
||||
operation: "edit_video",
|
||||
status: "done",
|
||||
title: "Cut the pauses and reframe for 9:16",
|
||||
outputs: [
|
||||
{
|
||||
artifactId: still.id,
|
||||
kind: "image",
|
||||
mimeType: "image/png",
|
||||
name: "still.png",
|
||||
size: still.size,
|
||||
width: 320,
|
||||
height: 180,
|
||||
},
|
||||
{
|
||||
artifactId: voice.id,
|
||||
kind: "audio",
|
||||
mimeType: "audio/wav",
|
||||
name: "voice-note.wav",
|
||||
size: voice.size,
|
||||
durationMs: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
await page.reload();
|
||||
|
||||
const card = page.getByTestId("media-card");
|
||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
||||
await expect(card).toHaveAttribute("data-status", "running");
|
||||
await expect(card).toContainText("Cut the pauses and reframe for 9:16");
|
||||
await expect(card).toContainText("Working…");
|
||||
await expect(card.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
|
||||
await captureScreenshot(page, testInfo, "media-card-running");
|
||||
|
||||
state = "done";
|
||||
await page.reload();
|
||||
await expect(card).toHaveAttribute("data-status", "done", { timeout: 30_000 });
|
||||
await expect(card).toContainText("Done");
|
||||
await expect(card.getByRole("progressbar")).toHaveCount(0);
|
||||
await expect(card.getByRole("img", { name: "still.png" })).toBeVisible();
|
||||
await expect(card.locator("audio")).toHaveCount(1);
|
||||
await expect(card.getByRole("link", { name: "Download voice-note.wav" })).toHaveAttribute(
|
||||
"download",
|
||||
"voice-note.wav",
|
||||
);
|
||||
await captureScreenshot(page, testInfo, "media-card-done");
|
||||
});
|
||||
193
apps/web/src/components/media/MediaCard.test.tsx
Normal file
193
apps/web/src/components/media/MediaCard.test.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import type { MediaJobBlock } from "@rakazo/contracts";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@lingui/react/macro", () => {
|
||||
const t = (parts: TemplateStringsArray, ...values: unknown[]) =>
|
||||
parts.reduce((text, part, index) => `${text}${part}${values[index] ?? ""}`, "");
|
||||
return { useLingui: () => ({ t }), Trans: ({ children }: { children: ReactNode }) => children };
|
||||
});
|
||||
vi.mock("../../pages/shell/message-cards", () => ({
|
||||
ArtifactImage: ({ artifactId, name }: { artifactId: string; name: string }) => (
|
||||
<img data-artifact={artifactId} alt={name} />
|
||||
),
|
||||
}));
|
||||
vi.mock("../ArtifactFileCard", () => ({
|
||||
ArtifactFileCard: ({ name }: { name: string }) => <span data-file-card="">{name}</span>,
|
||||
}));
|
||||
vi.mock("./MediaPlayer", () => ({
|
||||
MediaPlayer: ({ artifactId, kind }: { artifactId: string; kind: string }) => (
|
||||
<span data-player={kind} data-artifact={artifactId} />
|
||||
),
|
||||
}));
|
||||
vi.mock("../../lib/artifact-url", () => ({
|
||||
artifactUrl: (artifactId: string) => `/artifact/${artifactId}`,
|
||||
}));
|
||||
|
||||
import { exportPresetsFor, isActiveMediaStatus, MediaCardView } from "./MediaCard";
|
||||
|
||||
const target = { groupId: "group-1" };
|
||||
const video = {
|
||||
artifactId: "video-1",
|
||||
kind: "video" as const,
|
||||
mimeType: "video/mp4",
|
||||
name: "clip.mp4",
|
||||
size: 2048,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
};
|
||||
|
||||
function block(overrides: Partial<MediaJobBlock>): MediaJobBlock {
|
||||
return {
|
||||
kind: "media",
|
||||
jobId: "job-1",
|
||||
operation: "edit_video",
|
||||
status: "done",
|
||||
title: "Cut the intro",
|
||||
outputs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const everything = { export: true, transcribe: true };
|
||||
const noop = async () => undefined;
|
||||
|
||||
describe("media card", () => {
|
||||
it.each([
|
||||
["queued", "Queued"],
|
||||
["waiting", "Waiting for GPU"],
|
||||
["running", "Working…"],
|
||||
["done", "Done"],
|
||||
["failed", "Failed"],
|
||||
["cancelled", "Cancelled"],
|
||||
] as const)("labels a %s job as %s", (status, label) => {
|
||||
const html = renderToString(<MediaCardView block={block({ status })} target={target} />);
|
||||
expect(html).toContain(`data-status="${status}"`);
|
||||
expect(html).toContain(label);
|
||||
expect(html).toContain("Cut the intro");
|
||||
});
|
||||
|
||||
it("shows progress and a cancel action while the job runs, and no result actions", () => {
|
||||
const html = renderToString(
|
||||
<MediaCardView
|
||||
block={block({ status: "running", progress: 0.42, outputs: [video] })}
|
||||
target={target}
|
||||
available={everything}
|
||||
onAction={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('role="progressbar"');
|
||||
expect(html).toContain('aria-valuenow="42"');
|
||||
expect(html).toContain("width:42%");
|
||||
expect(html).toContain("Cancel");
|
||||
expect(html).not.toContain("Export");
|
||||
expect(html).not.toContain("Transcribe");
|
||||
});
|
||||
|
||||
it("leaves the progress value out when the backend does not report one", () => {
|
||||
const html = renderToString(
|
||||
<MediaCardView block={block({ status: "waiting" })} target={target} />,
|
||||
);
|
||||
expect(html).toContain('role="progressbar"');
|
||||
expect(html).not.toContain("aria-valuenow");
|
||||
});
|
||||
|
||||
it("offers download, export and transcribe for a finished video", () => {
|
||||
const html = renderToString(
|
||||
<MediaCardView
|
||||
block={block({ outputs: [video] })}
|
||||
target={target}
|
||||
available={everything}
|
||||
onAction={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-player="video"');
|
||||
expect(html).toContain('href="/artifact/video-1"');
|
||||
expect(html).toContain('download="clip.mp4"');
|
||||
expect(html).toContain('aria-label="Download clip.mp4"');
|
||||
expect(html).toContain("Export");
|
||||
expect(html).toContain("Transcribe");
|
||||
expect(html).not.toContain("progressbar");
|
||||
expect(html).not.toContain("Cancel");
|
||||
});
|
||||
|
||||
it("offers only what the space can run and what fits the output", () => {
|
||||
const audio = { ...video, artifactId: "audio-1", kind: "audio" as const, name: "take.m4a" };
|
||||
const onlyTranscribe = renderToString(
|
||||
<MediaCardView
|
||||
block={block({ outputs: [audio] })}
|
||||
target={target}
|
||||
available={{ export: true, transcribe: true }}
|
||||
onAction={noop}
|
||||
/>,
|
||||
);
|
||||
expect(onlyTranscribe).toContain('data-player="audio"');
|
||||
expect(onlyTranscribe).toContain("Transcribe");
|
||||
expect(onlyTranscribe).not.toContain("Export");
|
||||
|
||||
const nothingAvailable = renderToString(
|
||||
<MediaCardView block={block({ outputs: [video] })} target={target} onAction={noop} />,
|
||||
);
|
||||
expect(nothingAvailable).toContain("Download clip.mp4");
|
||||
expect(nothingAvailable).not.toContain("Export");
|
||||
expect(nothingAvailable).not.toContain("Transcribe");
|
||||
});
|
||||
|
||||
it("renders images as thumbnails and subtitle files as file cards", () => {
|
||||
const html = renderToString(
|
||||
<MediaCardView
|
||||
block={block({
|
||||
operation: "transcribe",
|
||||
outputs: [
|
||||
{ ...video, artifactId: "image-1", kind: "image", name: "still.png" },
|
||||
{ ...video, artifactId: "srt-1", kind: "file", name: "clip.srt" },
|
||||
{ ...video, artifactId: "vtt-1", kind: "file", name: "clip.vtt" },
|
||||
],
|
||||
})}
|
||||
target={target}
|
||||
available={everything}
|
||||
onAction={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-artifact="image-1"');
|
||||
expect(html).toContain("clip.srt");
|
||||
expect(html).toContain("clip.vtt");
|
||||
expect(html).not.toContain("Transcribe");
|
||||
});
|
||||
|
||||
it("shows why a job failed", () => {
|
||||
const html = renderToString(
|
||||
<MediaCardView
|
||||
block={block({ status: "failed", error: "GPU server is unreachable" })}
|
||||
target={target}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("GPU server is unreachable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportPresetsFor", () => {
|
||||
it("leaves out the shape the video already has, labelled by aspect ratio elsewhere", () => {
|
||||
expect(exportPresetsFor({ width: 1920, height: 1080 })).toEqual([
|
||||
"vertical",
|
||||
"square",
|
||||
"portrait",
|
||||
"classic",
|
||||
"cinema",
|
||||
]);
|
||||
expect(exportPresetsFor({ width: 1080, height: 1920, preset: "vertical" })).not.toContain(
|
||||
"vertical",
|
||||
);
|
||||
expect(exportPresetsFor({})).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isActiveMediaStatus", () => {
|
||||
it("treats queued, waiting and running as active", () => {
|
||||
expect(["queued", "waiting", "running"].every((s) => isActiveMediaStatus(s as never))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isActiveMediaStatus("done")).toBe(false);
|
||||
});
|
||||
});
|
||||
319
apps/web/src/components/media/MediaCard.tsx
Normal file
319
apps/web/src/components/media/MediaCard.tsx
Normal file
@ -0,0 +1,319 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import {
|
||||
MEDIA_JOB_ACTIVE_STATUSES,
|
||||
MEDIA_PRESETS,
|
||||
type MediaJobBlock,
|
||||
type MediaJobStatus,
|
||||
type MediaOperation,
|
||||
type MediaOutput,
|
||||
type MediaPresetId,
|
||||
MediaPresetIdSchema,
|
||||
} from "@rakazo/contracts";
|
||||
import {
|
||||
Button,
|
||||
buttonVariants,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@rakazo/ui-web";
|
||||
import {
|
||||
Captions,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Film,
|
||||
ImageIcon,
|
||||
type LucideIcon,
|
||||
Scissors,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import type { ArtifactTarget } from "../../lib/artifact-open";
|
||||
import { artifactUrl } from "../../lib/artifact-url";
|
||||
import { ArtifactImage } from "../../pages/shell/message-cards";
|
||||
import { ArtifactFileCard } from "../ArtifactFileCard";
|
||||
import { Shimmer } from "../ai/primitives";
|
||||
import { MediaPlayer } from "./MediaPlayer";
|
||||
|
||||
const CARD_CLASS =
|
||||
"w-[min(480px,96%)] rounded-[18px] border border-border bg-card/75 p-3.5 text-start shadow-xs";
|
||||
|
||||
export type MediaCardAction =
|
||||
| { kind: "export"; artifactId: string; preset: MediaPresetId }
|
||||
| { kind: "transcribe"; artifactId: string }
|
||||
| { kind: "cancel" };
|
||||
|
||||
/** Which follow-up jobs the space can run; actions it cannot run are not offered. */
|
||||
export interface MediaCardAvailability {
|
||||
export: boolean;
|
||||
transcribe: boolean;
|
||||
}
|
||||
|
||||
const OPERATION_ICONS: Record<MediaOperation, LucideIcon> = {
|
||||
generate_image: Sparkles,
|
||||
generate_video: Sparkles,
|
||||
edit_image: ImageIcon,
|
||||
edit_video: Scissors,
|
||||
export_video: Film,
|
||||
transcribe: Captions,
|
||||
};
|
||||
|
||||
export function isActiveMediaStatus(status: MediaJobStatus): boolean {
|
||||
return (MEDIA_JOB_ACTIVE_STATUSES as readonly MediaJobStatus[]).includes(status);
|
||||
}
|
||||
|
||||
/** Formats a video can be exported to: every preset except the shape it already has. */
|
||||
export function exportPresetsFor(output: Pick<MediaOutput, "width" | "height" | "preset">) {
|
||||
const ratio = output.width && output.height ? output.width / output.height : null;
|
||||
return MediaPresetIdSchema.options.filter((id) => {
|
||||
if (id === output.preset) return false;
|
||||
const preset = MEDIA_PRESETS[id];
|
||||
return ratio === null || Math.abs(preset.width / preset.height - ratio) > 0.01;
|
||||
});
|
||||
}
|
||||
|
||||
function StatusLabel({ status }: { status: MediaJobStatus }) {
|
||||
const { t } = useLingui();
|
||||
if (status === "running") {
|
||||
return (
|
||||
<span className="text-ui-body-sm font-medium">
|
||||
<Shimmer>{t`Working…`}</Shimmer>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const label =
|
||||
status === "queued"
|
||||
? t`Queued`
|
||||
: status === "waiting"
|
||||
? t`Waiting for GPU`
|
||||
: status === "done"
|
||||
? t`Done`
|
||||
: status === "failed"
|
||||
? t`Failed`
|
||||
: t`Cancelled`;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-ui-body-sm font-medium",
|
||||
status === "done"
|
||||
? "text-success"
|
||||
: status === "failed"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OutputActions({
|
||||
output,
|
||||
available,
|
||||
busy,
|
||||
onAction,
|
||||
}: {
|
||||
output: MediaOutput;
|
||||
available: MediaCardAvailability;
|
||||
busy: boolean;
|
||||
onAction?: (action: MediaCardAction) => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const name = output.name;
|
||||
const presets = output.kind === "video" ? exportPresetsFor(output) : [];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<a
|
||||
href={artifactUrl(output.artifactId)}
|
||||
download={name}
|
||||
aria-label={t`Download ${name}`}
|
||||
title={t`Download ${name}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Download />
|
||||
</a>
|
||||
{onAction && available.export && presets.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={busy}
|
||||
render={<Button variant="outline" size="sm" data-testid="media-export" />}
|
||||
>
|
||||
<Film />
|
||||
{t`Export`}
|
||||
<ChevronDown />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-auto min-w-24">
|
||||
{presets.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
onClick={() => onAction({ kind: "export", artifactId: output.artifactId, preset })}
|
||||
>
|
||||
{MEDIA_PRESETS[preset].aspect}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{onAction && available.transcribe && (output.kind === "video" || output.kind === "audio") ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => onAction({ kind: "transcribe", artifactId: output.artifactId })}
|
||||
>
|
||||
<Captions />
|
||||
{t`Transcribe`}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live card of a media job: what it makes, how far it is, and the results with the
|
||||
* follow-ups that fit them (export other formats, transcribe). Nothing else is shown.
|
||||
*/
|
||||
export function MediaCardView({
|
||||
block,
|
||||
target,
|
||||
available = { export: false, transcribe: false },
|
||||
onAction,
|
||||
}: {
|
||||
block: MediaJobBlock;
|
||||
target: ArtifactTarget;
|
||||
available?: MediaCardAvailability;
|
||||
/** Starts a follow-up job or cancels this one; rejects with a message to show. */
|
||||
onAction?: (action: MediaCardAction) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
const titleId = useId();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const active = isActiveMediaStatus(block.status);
|
||||
const Icon = OPERATION_ICONS[block.operation];
|
||||
const percent = Math.round((block.progress ?? 0) * 100);
|
||||
const images = block.outputs.filter((output) => output.kind === "image");
|
||||
const others = block.outputs.filter((output) => output.kind !== "image");
|
||||
|
||||
const act = onAction
|
||||
? (action: MediaCardAction) => {
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
void onAction(action)
|
||||
.catch((error: unknown) =>
|
||||
setActionError(error instanceof Error && error.message ? error.message : t`Failed`),
|
||||
)
|
||||
.finally(() => setBusy(false));
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="my-2 flex justify-start">
|
||||
<div data-testid="media-card" data-status={block.status} className={CARD_CLASS}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<Icon aria-hidden="true" className="size-icon-md shrink-0 text-muted-foreground" />
|
||||
<span
|
||||
id={titleId}
|
||||
className="truncate text-ui-body font-medium text-foreground"
|
||||
dir="auto"
|
||||
title={block.title}
|
||||
>
|
||||
{block.title}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<StatusLabel status={block.status} />
|
||||
{active && act ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => act({ kind: "cancel" })}
|
||||
>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
{active ? (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-labelledby={titleId}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={block.progress === undefined ? undefined : percent}
|
||||
className="mt-2.5 h-1 overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full bg-primary transition-[width] duration-500",
|
||||
block.progress === undefined && "w-1/3 animate-pulse",
|
||||
)}
|
||||
style={block.progress === undefined ? undefined : { width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{block.status === "failed" && block.error ? (
|
||||
<p className="mt-2 text-ui-body-sm text-destructive" dir="auto">
|
||||
{block.error}
|
||||
</p>
|
||||
) : null}
|
||||
{images.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{images.map((output) => (
|
||||
<ArtifactImage
|
||||
key={output.artifactId}
|
||||
target={target}
|
||||
artifactId={output.artifactId}
|
||||
name={output.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{others.map((output) => (
|
||||
<div key={output.artifactId} className="mt-3 flex flex-col gap-1.5">
|
||||
{output.kind === "video" || output.kind === "audio" ? (
|
||||
<>
|
||||
<MediaPlayer
|
||||
artifactId={output.artifactId}
|
||||
kind={output.kind}
|
||||
mimeType={output.mimeType}
|
||||
name={output.name}
|
||||
posterArtifactId={output.posterArtifactId}
|
||||
width={output.width}
|
||||
height={output.height}
|
||||
/>
|
||||
{active ? null : (
|
||||
<OutputActions output={output} available={available} busy={busy} onAction={act} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ArtifactFileCard
|
||||
target={target}
|
||||
artifactId={output.artifactId}
|
||||
name={output.name}
|
||||
mimeType={output.mimeType}
|
||||
size={output.size}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{actionError ? (
|
||||
<p role="alert" className="mt-2 text-ui-body-sm text-destructive" dir="auto">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The media card in a conversation, with its follow-up actions wired to the media job RPCs. */
|
||||
export function MediaCard({ block, target }: { block: MediaJobBlock; target: ArtifactTarget }) {
|
||||
return <MediaCardView block={block} target={target} />;
|
||||
}
|
||||
33
apps/web/src/components/media/MediaPlayer.tsx
Normal file
33
apps/web/src/components/media/MediaPlayer.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
// Stand-in for B1's media player; replaced by B1's component when its branch is merged.
|
||||
import { artifactUrl } from "../../lib/artifact-url";
|
||||
|
||||
export function MediaPlayer({
|
||||
artifactId,
|
||||
kind,
|
||||
name,
|
||||
posterArtifactId,
|
||||
}: {
|
||||
artifactId: string;
|
||||
kind: "video" | "audio";
|
||||
mimeType: string;
|
||||
name: string;
|
||||
posterArtifactId?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
if (kind === "audio") {
|
||||
// biome-ignore lint/a11y/useMediaCaption: generated media has no caption track yet.
|
||||
return <audio controls preload="metadata" src={artifactUrl(artifactId)} aria-label={name} />;
|
||||
}
|
||||
return (
|
||||
// biome-ignore lint/a11y/useMediaCaption: generated media has no caption track yet.
|
||||
<video
|
||||
controls
|
||||
preload="metadata"
|
||||
src={artifactUrl(artifactId)}
|
||||
poster={posterArtifactId ? artifactUrl(posterArtifactId) : undefined}
|
||||
aria-label={name}
|
||||
className="max-h-80 w-full rounded-xl bg-muted object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
4
apps/web/src/lib/artifact-url.ts
Normal file
4
apps/web/src/lib/artifact-url.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// Stand-in for B1's artifact URL helper; replaced by B1's file when its branch is merged.
|
||||
export function artifactUrl(artifactId: string): string {
|
||||
return `/api/artifacts/${encodeURIComponent(artifactId)}`;
|
||||
}
|
||||
@ -169,6 +169,7 @@ import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
|
||||
import { DataTrustPanel } from "../components/DataTrustPanel";
|
||||
import { MainInbox } from "../components/MainInbox";
|
||||
import { MessageHoverMetadata } from "../components/MessageHoverMetadata";
|
||||
import { MediaCard } from "../components/media/MediaCard";
|
||||
import { OoGroupStage, OoStage } from "../components/stage/OoStage";
|
||||
import { TradingModeIndicator } from "../components/TradingModeIndicator";
|
||||
import { SkillDraftCard } from "../components/teach/SkillDraftCard";
|
||||
@ -7960,6 +7961,8 @@ const MessageView = memo(function MessageView({
|
||||
);
|
||||
}
|
||||
if (block.kind === "cloud_agent") return <CloudAgentCard key={i} block={block} />;
|
||||
if (block.kind === "media")
|
||||
return <MediaCard key={i} block={block} target={artifactTarget} />;
|
||||
if (block.kind === "skill_draft") {
|
||||
return (
|
||||
<div key={i} className="flex justify-start">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user