{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"generated-media","type":"registry:ui","title":"Generated Media Result","description":"The result card for AI-generated images, audio and video — queue and stage states, variants, download and regenerate, and a safety block that isn't an error.","author":"Scrim UI (https://scrimui.dev)","categories":["tool-calls"],"docs":"https://scrimui.dev/components/generated-media","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/generated-media/generated-media.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * The result card for generated media — images, audio, video.\n *\n * The rules this component holds:\n *\n * **Progress is a stage, never a fake percentage.** The model does not know\n * it is \"63% done\", and a bar that sprints to 90% then stalls teaches the\n * user to distrust every honest bar after it. Queued shows a queue position;\n * generating shows the current stage in words.\n *\n * **Blocked is not failed.** A safety refusal (content policy) and an\n * infrastructure failure look identical if both render a red box — but one\n * asks the user to rephrase and the other asks them to retry. They get\n * different surfaces, different copy, and different actions.\n *\n * **The media's accessibility is a prop, not an afterthought.** The host\n * hands in the `<img alt>`, `<audio>` or `<video>` element; this frame\n * carries the caption and, when the media can't render, a labelled\n * fallback instead of a broken-icon rectangle.\n */\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\nexport type MediaKind = \"image\" | \"audio\" | \"video\";\n\n/** `blocked` is a safety refusal — distinct from `failed` on purpose. */\nexport type MediaStatus = \"queued\" | \"generating\" | \"ready\" | \"failed\" | \"cancelled\" | \"blocked\";\n\nexport type MediaVariant = {\n  id: string;\n  label?: string;\n};\n\nexport type GeneratedMediaResultProps = {\n  kind: MediaKind;\n  status: MediaStatus;\n  /** The prompt that produced it — always visible, so a result is re-usable. */\n  prompt: string;\n  /** Key generation parameters, e.g. [\"1024×1024\", \"seed 42\"]. */\n  params?: string[];\n  /** Current stage while generating, in words — \"Upsampling\", never \"63%\". */\n  stage?: string;\n  /** Position in line while queued. */\n  queuePosition?: number;\n  /** The media element — <img>, <audio controls>, <video controls>. Rendered only when ready. */\n  children?: React.ReactNode;\n  /** Text fallback when a ready result has no media element to show. */\n  mediaAlt?: string;\n  caption?: string;\n  variants?: MediaVariant[];\n  currentVariantId?: string;\n  onVariantChange?: (id: string) => void;\n  errorMessage?: string;\n  /** Why the safety system refused — phrased to help the user rephrase. */\n  blockedReason?: string;\n  onDownload?: () => void;\n  onRegenerate?: () => void;\n  onCancel?: () => void;\n  onRetry?: () => void;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Icons                                                               */\n/* ------------------------------------------------------------------ */\n\nconst ICON_PROPS = {\n  viewBox: \"0 0 24 24\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 2,\n  strokeLinecap: \"round\",\n  strokeLinejoin: \"round\",\n} as const;\n\nfunction ImageIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"15\" height=\"15\">\n      <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" ry=\"2\" />\n      <circle cx=\"9\" cy=\"9\" r=\"2\" />\n      <path d=\"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21\" />\n    </svg>\n  );\n}\n\nfunction AudioIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"15\" height=\"15\">\n      <path d=\"M2 13a2 2 0 0 0 2-2V7a2 2 0 0 1 4 0v13a2 2 0 0 0 4 0V4a2 2 0 0 1 4 0v13a2 2 0 0 0 4 0v-4a2 2 0 0 1 2-2\" />\n    </svg>\n  );\n}\n\nfunction VideoIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"15\" height=\"15\">\n      <path d=\"m22 8-6 4 6 4V8Z\" />\n      <rect width=\"14\" height=\"12\" x=\"2\" y=\"6\" rx=\"2\" ry=\"2\" />\n    </svg>\n  );\n}\n\nfunction ShieldIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"20\" height=\"20\">\n      <path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\" />\n    </svg>\n  );\n}\n\nfunction ErrorIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"20\" height=\"20\">\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <line x1=\"12\" x2=\"12\" y1=\"8\" y2=\"12\" />\n      <line x1=\"12.01\" x2=\"12\" y1=\"16\" y2=\"16\" />\n    </svg>\n  );\n}\n\nfunction DownloadIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"14\" height=\"14\">\n      <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n      <polyline points=\"7 10 12 15 17 10\" />\n      <line x1=\"12\" x2=\"12\" y1=\"15\" y2=\"3\" />\n    </svg>\n  );\n}\n\nfunction RegenerateIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"14\" height=\"14\">\n      <path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\" />\n      <path d=\"M21 3v5h-5\" />\n      <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\" />\n      <path d=\"M8 16H3v5\" />\n    </svg>\n  );\n}\n\nfunction XIcon() {\n  return (\n    <svg {...ICON_PROPS} width=\"14\" height=\"14\">\n      <path d=\"M18 6 6 18\" />\n      <path d=\"m6 6 12 12\" />\n    </svg>\n  );\n}\n\nconst KIND_META: Record<MediaKind, { label: string; icon: React.ReactNode }> = {\n  image: { label: \"Image\", icon: <ImageIcon /> },\n  audio: { label: \"Audio\", icon: <AudioIcon /> },\n  video: { label: \"Video\", icon: <VideoIcon /> },\n};\n\n/* ------------------------------------------------------------------ */\n/* GeneratedMediaResult                                                */\n/* ------------------------------------------------------------------ */\n\nexport function GeneratedMediaResult({\n  kind,\n  status,\n  prompt,\n  params = [],\n  stage,\n  queuePosition,\n  children,\n  mediaAlt,\n  caption,\n  variants,\n  currentVariantId,\n  onVariantChange,\n  errorMessage,\n  blockedReason,\n  onDownload,\n  onRegenerate,\n  onCancel,\n  onRetry,\n  className = \"\",\n}: GeneratedMediaResultProps) {\n  const meta = KIND_META[kind];\n  const running = status === \"queued\" || status === \"generating\";\n\n  const actionCls =\n    \"inline-flex items-center gap-1 rounded-md border border-zinc-200 px-2 py-1 text-[11px] font-medium text-zinc-600 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800\";\n\n  return (\n    <figure\n      className={`overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900 ${className}`}\n    >\n      {/* Header — wraps on narrow cards; actions are never squeezed off. */}\n      <div className=\"flex flex-wrap items-center gap-2 border-b border-zinc-100 px-3 py-2 dark:border-zinc-800/60\">\n        <span className=\"flex items-center gap-1.5 text-[13px] font-medium text-zinc-700 dark:text-zinc-200\">\n          <span className=\"text-zinc-400 dark:text-zinc-500\">{meta.icon}</span>\n          {meta.label}\n        </span>\n        {status === \"generating\" && (\n          <span className=\"inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-950/60 dark:text-blue-300\">\n            <span className=\"h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500\" />\n            Generating\n          </span>\n        )}\n        {status === \"queued\" && (\n          <span className=\"rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400\">\n            Queued{queuePosition != null ? ` · #${queuePosition}` : \"\"}\n          </span>\n        )}\n        {status === \"failed\" && (\n          <span className=\"rounded-full bg-red-50 px-2 py-0.5 text-[11px] font-medium text-red-700 dark:bg-red-950/60 dark:text-red-400\">\n            Failed\n          </span>\n        )}\n        {status === \"blocked\" && (\n          <span className=\"rounded-full bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:bg-amber-950/60 dark:text-amber-400\">\n            Blocked by policy\n          </span>\n        )}\n        {status === \"cancelled\" && (\n          <span className=\"rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400\">\n            Cancelled\n          </span>\n        )}\n\n        <span className=\"ml-auto flex items-center gap-1.5\">\n          {running && onCancel && (\n            <button type=\"button\" onClick={onCancel} className={actionCls}>\n              <XIcon />\n              Cancel\n            </button>\n          )}\n          {status === \"failed\" && onRetry && (\n            <button type=\"button\" onClick={onRetry} className={actionCls}>\n              <RegenerateIcon />\n              Retry\n            </button>\n          )}\n          {(status === \"ready\" || status === \"cancelled\") && onRegenerate && (\n            <button type=\"button\" onClick={onRegenerate} className={actionCls}>\n              <RegenerateIcon />\n              Regenerate\n            </button>\n          )}\n          {status === \"ready\" && onDownload && (\n            <button type=\"button\" onClick={onDownload} className={actionCls}>\n              <DownloadIcon />\n              Download\n            </button>\n          )}\n        </span>\n      </div>\n\n      {/* Body — fixed minimum height so status flips don't shove the page. */}\n      <div className=\"flex min-h-[220px] items-center justify-center bg-zinc-50 dark:bg-zinc-950/40\">\n        {status === \"queued\" && (\n          <div className=\"px-6 py-10 text-center\">\n            <p className=\"text-[13px] font-medium text-zinc-600 dark:text-zinc-300\">\n              {queuePosition != null ? `#${queuePosition} in the queue` : \"Waiting for a slot\"}\n            </p>\n            <p className=\"mt-1 text-xs text-zinc-400 dark:text-zinc-500\">Generation starts as soon as a worker is free.</p>\n          </div>\n        )}\n\n        {status === \"generating\" && (\n          <div className=\"w-full px-6 py-10\">\n            <div className=\"mx-auto max-w-[280px] space-y-2\">\n              <div className=\"h-28 animate-pulse rounded-lg bg-zinc-200/70 dark:bg-zinc-800\" />\n              <div className=\"h-2.5 w-2/3 animate-pulse rounded bg-zinc-200/70 dark:bg-zinc-800\" />\n              <div className=\"h-2.5 w-1/3 animate-pulse rounded bg-zinc-200/70 dark:bg-zinc-800\" />\n            </div>\n            <p className=\"mt-4 text-center text-xs text-zinc-500 dark:text-zinc-400\">\n              {stage ?? \"Generating…\"}\n            </p>\n          </div>\n        )}\n\n        {status === \"ready\" &&\n          (children ?? (\n            <div className=\"px-6 py-10 text-center\">\n              <span className=\"mx-auto block w-fit text-zinc-300 dark:text-zinc-600\">{meta.icon}</span>\n              <p className=\"mt-2 text-xs text-zinc-500 dark:text-zinc-400\">\n                {mediaAlt ?? `The ${meta.label.toLowerCase()} could not be displayed.`}\n              </p>\n            </div>\n          ))}\n\n        {status === \"failed\" && (\n          <div className=\"px-6 py-10 text-center\">\n            <span className=\"mx-auto block w-fit text-red-400 dark:text-red-500\">\n              <ErrorIcon />\n            </span>\n            <p className=\"mt-2 text-[13px] font-medium text-zinc-700 dark:text-zinc-200\">Generation failed</p>\n            <p className=\"mx-auto mt-1 max-w-[320px] text-xs text-zinc-500 dark:text-zinc-400\">\n              {errorMessage ?? \"The worker stopped mid-generation. Nothing was charged — retry to start over.\"}\n            </p>\n          </div>\n        )}\n\n        {status === \"blocked\" && (\n          <div className=\"px-6 py-10 text-center\">\n            <span className=\"mx-auto block w-fit text-amber-500 dark:text-amber-400\">\n              <ShieldIcon />\n            </span>\n            <p className=\"mt-2 text-[13px] font-medium text-zinc-700 dark:text-zinc-200\">\n              Blocked by the content policy\n            </p>\n            <p className=\"mx-auto mt-1 max-w-[320px] text-xs text-zinc-500 dark:text-zinc-400\">\n              {blockedReason ?? \"The prompt was refused before generation started. Rephrase it and try again — this is not a retryable error.\"}\n            </p>\n          </div>\n        )}\n\n        {status === \"cancelled\" && (\n          <div className=\"px-6 py-10 text-center\">\n            <p className=\"text-[13px] font-medium text-zinc-500 dark:text-zinc-400\">Cancelled before it finished</p>\n            <p className=\"mt-1 text-xs text-zinc-400 dark:text-zinc-500\">Nothing was charged. Regenerate to start over.</p>\n          </div>\n        )}\n      </div>\n\n      {/* Footer — the prompt is the re-use path, so it is always visible. */}\n      <figcaption className=\"border-t border-zinc-100 px-3 py-2 dark:border-zinc-800/60\">\n        <p className=\"truncate text-xs text-zinc-600 dark:text-zinc-300\" title={prompt}>\n          “{prompt}”\n        </p>\n        <div className=\"mt-1.5 flex flex-wrap items-center gap-1.5\">\n          {params.map((p) => (\n            <span\n              key={p}\n              className=\"rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400\"\n            >\n              {p}\n            </span>\n          ))}\n          {variants && variants.length > 1 && (\n            <span className=\"ml-auto flex items-center gap-1\" role=\"group\" aria-label=\"Variants\">\n              {variants.map((v, i) => {\n                const active = v.id === currentVariantId;\n                return (\n                  <button\n                    key={v.id}\n                    type=\"button\"\n                    onClick={() => onVariantChange?.(v.id)}\n                    aria-pressed={active}\n                    aria-label={v.label ?? `Variant ${i + 1}`}\n                    className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${\n                      active\n                        ? \"bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\"\n                        : \"border border-zinc-200 text-zinc-500 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800\"\n                    }`}\n                  >\n                    {v.label ?? `${i + 1}`}\n                  </button>\n                );\n              })}\n            </span>\n          )}\n        </div>\n        {caption && <p className=\"mt-1.5 text-[11px] text-zinc-400 dark:text-zinc-500\">{caption}</p>}\n      </figcaption>\n    </figure>\n  );\n}\n","type":"registry:ui","target":"components/ui/generated-media.tsx"}]}