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, ScanSearch, 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 } 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; name: 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 = { generate_image: Sparkles, generate_video: Sparkles, edit_image: ImageIcon, edit_video: Scissors, export_video: Film, transcribe: Captions, analyze_video: ScanSearch, }; 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) { 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 ( {t`Working…`} ); } const label = status === "queued" ? t`Queued` : status === "waiting" ? t`Waiting for GPU` : status === "done" ? t`Done` : status === "failed" ? t`Failed` : t`Cancelled`; return ( {label} ); } 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 (
{onAction && available.export && presets.length > 0 ? ( } > {t`Export`} {presets.map((preset) => ( onAction({ kind: "export", artifactId: output.artifactId, preset })} > {MEDIA_PRESETS[preset].aspect} ))} ) : null} {onAction && available.transcribe && (output.kind === "video" || output.kind === "audio") ? ( ) : null}
); } /** * 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, available = NOTHING_AVAILABLE, onAction, }: { block: MediaJobBlock; available?: MediaCardAvailability; /** Starts a follow-up job or cancels this one; rejects with a message to show. */ onAction?: (action: MediaCardAction) => Promise; }) { const { t } = useLingui(); const titleId = useId(); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(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 (
{active && act ? ( ) : null}
{active ? (
) : null} {block.status === "failed" && block.error ? (

{block.error}

) : null} {images.length > 0 ? (
{images.map((output) => ( ))}
) : null} {others.map((output) => (
{output.kind === "video" || output.kind === "audio" ? ( <> {active ? null : ( )} ) : ( )}
))} {actionError ? (

{actionError}

) : null}
); } const CAPABILITIES_TTL_MS = 60_000; let capabilities: | { conversation: string; at: number; value: Promise } | undefined; /** * What the card's space can run, shared by every card of the conversation on screen and * refreshed after a minute. Asked through a job, since the main conversation can live in another * space than the one selected. */ function loadAvailability(target: ArtifactTarget, jobId: string): Promise { const conversation = "botId" in target ? `bot:${target.botId}` : `group:${target.groupId}`; if ( !capabilities || capabilities.conversation !== conversation || Date.now() - capabilities.at > CAPABILITIES_TTL_MS ) { capabilities = { conversation, at: Date.now(), value: rpc.media .capabilities({ jobId }) .then((value) => ({ export: value.export_video === true, transcribe: value.transcribe === true, })) .catch(() => NOTHING_AVAILABLE), }; } return capabilities.value; } function useAvailability( enabled: boolean, target: ArtifactTarget, jobId: string, ): MediaCardAvailability { const [available, setAvailable] = useState(NOTHING_AVAILABLE); useEffect(() => { if (!enabled) return; let current = true; void loadAvailability(target, jobId).then((value) => { if (current) setAvailable(value); }); return () => { current = false; }; }, [enabled, target, jobId]); 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"), target, block.jobId, ); 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] }, } : // The new card is titled with the file it transcribes. { operation: "transcribe", input: { artifact_id: action.artifactId, title: action.name }, }), }); }; return ; }