Claude 74c3685397
fix(web): offer media card follow-ups on any settled job
Failed or cancelled jobs can still carry finished outputs, so the card asks
for the space's capabilities whenever a settled job shows video or audio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:08:37 +00:00

386 lines
12 KiB
TypeScript

import { useLingui } from "@lingui/react/macro";
import type {
MediaJobBlock,
MediaJobStatus,
MediaOperation,
MediaOutput,
MediaPresetId,
} from "@rakazo/contracts";
import { MEDIA_JOB_ACTIVE_STATUSES, MEDIA_PRESETS, MediaPresetIdSchema } from "@rakazo/contracts";
import {
Button,
buttonVariants,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@rakazo/ui-web";
import type { LucideIcon } from "lucide-react";
import { Captions, ChevronDown, Download, Film, ImageIcon, Scissors, Sparkles } from "lucide-react";
import { useEffect, useId, useState } from "react";
import type { ArtifactTarget } from "../../lib/artifact-open";
import { artifactUrl } from "../../lib/artifact-url";
import { rpc, selectedSpaceId } from "../../lib/rpc";
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 NOTHING_AVAILABLE: MediaCardAvailability = { export: false, transcribe: false };
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: true })}
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 = NOTHING_AVAILABLE,
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>
);
}
const CAPABILITIES_TTL_MS = 60_000;
let capabilities:
| { spaceId: string | null; at: number; value: Promise<MediaCardAvailability> }
| undefined;
/** What the space can run, shared by every card on screen and refreshed after a minute. */
function loadAvailability(): Promise<MediaCardAvailability> {
const spaceId = selectedSpaceId();
if (
!capabilities ||
capabilities.spaceId !== spaceId ||
Date.now() - capabilities.at > CAPABILITIES_TTL_MS
) {
capabilities = {
spaceId,
at: Date.now(),
value: rpc.media
.capabilities()
.then((value) => ({
export: value.export_video === true,
transcribe: value.transcribe === true,
}))
.catch(() => NOTHING_AVAILABLE),
};
}
return capabilities.value;
}
function useAvailability(enabled: boolean): MediaCardAvailability {
const [available, setAvailable] = useState(NOTHING_AVAILABLE);
useEffect(() => {
if (!enabled) return;
let current = true;
void loadAvailability().then((value) => {
if (current) setAvailable(value);
});
return () => {
current = false;
};
}, [enabled]);
return available;
}
/** The media card in a conversation, with its follow-up actions wired to the media job RPCs. */
export function MediaCard({
block,
target,
messageId,
}: {
block: MediaJobBlock;
target: ArtifactTarget;
/** The message carrying the card; a follow-up job is posted by the same bot. */
messageId: string;
}) {
// Follow-ups apply to video and audio of a settled job only; other cards never ask.
const available = useAvailability(
!isActiveMediaStatus(block.status) &&
block.outputs.some((output) => output.kind === "video" || output.kind === "audio"),
);
const onAction = async (action: MediaCardAction) => {
if (action.kind === "cancel") {
await rpc.media.jobs.cancel({ jobId: block.jobId });
return;
}
await rpc.media.jobs.start({
...target,
messageId,
...(action.kind === "export"
? {
operation: "export_video",
input: { artifact_id: action.artifactId, presets: [action.preset] },
}
: { operation: "transcribe", input: { artifactId: action.artifactId } }),
});
};
return <MediaCardView block={block} target={target} available={available} onAction={onAction} />;
}