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
320 lines
9.9 KiB
TypeScript
320 lines
9.9 KiB
TypeScript
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} />;
|
|
}
|