feat(frontend): add rich media previews for Builder node outputs and file inputs (#12432)

### Changes
- Add YouTube/Vimeo embed support to `VideoRenderer` — URLs render as
embedded
  iframe players instead of plain text
- Add new `AudioRenderer` — HTTP audio URLs (.mp3, .wav, .ogg, .m4a,
.aac,
  .flac) and data URIs render as inline audio players
- Add new `LinkRenderer` — any HTTP/HTTPS URL not claimed by a media
renderer
  becomes a clickable link with an external-link icon
- Add media preview button to `FileInput` — uploaded audio, video, and
image
files show an Eye icon that opens a preview dialog reusing the
OutputRenderer
  system
- Update `ContentRenderer` shortContent gate to allow new renderers
through in
  node previews


https://github.com/user-attachments/assets/eea27fb7-3870-4a1e-8d08-ba23b6e07d74

### Test plan
- [x] `pnpm vitest run src/components/contextual/OutputRenderers/` — 36
tests
  passing
- [x] `pnpm format && pnpm lint && pnpm types` — all clean
- [x] Manual: run a block that outputs a YouTube URL → embedded player
- [x] Manual: run a block that outputs an audio file URL → audio player
- [x] Manual: run a block that outputs a generic URL → clickable link
- [x] Manual: upload an audio/video/image file to a file input → Eye
icon
  appears, clicking opens preview dialog
This commit is contained in:
Abhimanyu Yadav
2026-03-17 12:39:02 +05:30
committed by GitHub
parent 60bc49ba50
commit f0800b9420
9 changed files with 562 additions and 17 deletions

View File

@@ -35,6 +35,8 @@ export const ContentRenderer: React.FC<{
renderer?.name === "ImageRenderer" ||
renderer?.name === "VideoRenderer" ||
renderer?.name === "WorkspaceFileRenderer" ||
renderer?.name === "AudioRenderer" ||
renderer?.name === "LinkRenderer" ||
!shortContent
) {
return (

View File

@@ -1,10 +1,76 @@
import { FileTextIcon, TrashIcon, UploadIcon, X } from "@phosphor-icons/react";
import {
FileTextIcon,
Eye,
TrashIcon,
UploadIcon,
X,
} from "@phosphor-icons/react";
import { useRef, useState } from "react";
import { Button } from "../Button/Button";
import { formatFileSize, getFileLabel } from "./helpers";
import { cn } from "@/lib/utils";
import { parseWorkspaceURI } from "@/lib/workspace-uri";
import { Text } from "../Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { globalRegistry } from "@/components/contextual/OutputRenderers";
function PreviewButton({
value,
title,
contentType,
}: {
value: string;
title: string;
contentType?: string;
}) {
const metadata = contentType ? { mimeType: contentType } : undefined;
const renderer = globalRegistry.getRenderer(value, metadata);
if (!renderer) return null;
return (
<Dialog title={title}>
<Dialog.Trigger>
<Button
variant="outline"
size="small"
className="h-7 w-7 min-w-0 flex-shrink-0 border-zinc-300 p-0 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-500"
type="button"
aria-label="Preview file"
>
<Eye size={14} />
</Button>
</Dialog.Trigger>
<Dialog.Content>
<div className="overflow-hidden [&>*]:rounded-xlarge">
{renderer.render(value, metadata)}
</div>
</Dialog.Content>
</Dialog>
);
}
function getMimeFromDataURI(value: string): string | null {
const match = value.match(/^data:([^;,]+)/);
return match?.[1] || null;
}
function isPreviewableFile(
value: string | undefined,
contentType: string | undefined,
): boolean {
if (!value) return false;
const mimeType =
contentType ||
parseWorkspaceURI(value)?.mimeType ||
(value.startsWith("data:") ? getMimeFromDataURI(value) : null);
if (!mimeType) return false;
const normalized = mimeType.toLowerCase();
return (
normalized.startsWith("audio/") ||
normalized.startsWith("video/") ||
normalized.startsWith("image/")
);
}
type UploadFileResult = {
file_name: string;
@@ -292,12 +358,24 @@ export function FileInput(props: Props) {
</Text>
)}
</div>
{isPreviewableFile(value, fileInfo?.content_type) && (
<PreviewButton
value={value}
title={
fileInfo
? getFileLabel(fileInfo.name, fileInfo.content_type)
: "Preview"
}
contentType={fileInfo?.content_type}
/>
)}
<Button
variant="outline"
size="small"
className="h-7 w-7 min-w-0 flex-shrink-0 border-zinc-300 p-0 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-500"
onClick={handleClear}
type="button"
aria-label="Clear file"
>
<X size={14} />
</Button>
@@ -363,10 +441,29 @@ export function FileInput(props: Props) {
<span>{fileInfo ? formatFileSize(fileInfo.size) : ""}</span>
</div>
</div>
<TrashIcon
className="h-5 w-5 cursor-pointer text-black"
onClick={handleClear}
/>
<div className="flex items-center gap-2">
{isPreviewableFile(value, fileInfo?.content_type) && (
<PreviewButton
value={value}
title={
fileInfo
? getFileLabel(fileInfo.name, fileInfo.content_type)
: "Preview"
}
contentType={fileInfo?.content_type}
/>
)}
<Button
variant="outline"
size="small"
type="button"
onClick={handleClear}
aria-label="Clear file"
className="h-7 w-7 min-w-0 flex-shrink-0 border-zinc-300 p-0 text-gray-500 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-500"
>
<TrashIcon className="h-5 w-5" />
</Button>
</div>
</div>
</div>
{showStorageNote && mode === "upload" && (

View File

@@ -3,17 +3,21 @@ import { textRenderer } from "./renderers/TextRenderer";
import { codeRenderer } from "./renderers/CodeRenderer";
import { imageRenderer } from "./renderers/ImageRenderer";
import { videoRenderer } from "./renderers/VideoRenderer";
import { audioRenderer } from "./renderers/AudioRenderer";
import { jsonRenderer } from "./renderers/JSONRenderer";
import { markdownRenderer } from "./renderers/MarkdownRenderer";
import { workspaceFileRenderer } from "./renderers/WorkspaceFileRenderer";
import { linkRenderer } from "./renderers/LinkRenderer";
// Register all renderers in priority order
globalRegistry.register(workspaceFileRenderer);
globalRegistry.register(videoRenderer);
globalRegistry.register(audioRenderer);
globalRegistry.register(imageRenderer);
globalRegistry.register(codeRenderer);
globalRegistry.register(markdownRenderer);
globalRegistry.register(jsonRenderer);
globalRegistry.register(linkRenderer);
globalRegistry.register(textRenderer);
export { globalRegistry };

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { audioRenderer } from "./AudioRenderer";
describe("AudioRenderer canRender", () => {
it("detects audio file URLs by extension", () => {
expect(audioRenderer.canRender("https://example.com/song.mp3")).toBe(true);
expect(audioRenderer.canRender("https://example.com/track.wav")).toBe(true);
expect(audioRenderer.canRender("https://example.com/audio.ogg")).toBe(true);
expect(audioRenderer.canRender("https://example.com/music.flac")).toBe(
true,
);
expect(audioRenderer.canRender("https://example.com/podcast.m4a")).toBe(
true,
);
expect(audioRenderer.canRender("https://example.com/voice.aac")).toBe(true);
});
it("defers .webm to VideoRenderer (higher priority)", () => {
expect(audioRenderer.canRender("https://example.com/sound.webm")).toBe(
false,
);
});
it("detects audio data URIs", () => {
expect(audioRenderer.canRender("data:audio/mpeg;base64,abc")).toBe(true);
expect(audioRenderer.canRender("data:audio/wav;base64,abc")).toBe(true);
});
it("detects via metadata type", () => {
expect(audioRenderer.canRender("anything", { type: "audio" })).toBe(true);
});
it("detects via metadata mimeType", () => {
expect(
audioRenderer.canRender("anything", { mimeType: "audio/mpeg" }),
).toBe(true);
expect(audioRenderer.canRender("anything", { mimeType: "audio/wav" })).toBe(
true,
);
});
it("rejects non-audio URLs", () => {
expect(audioRenderer.canRender("https://example.com/video.mp4")).toBe(
false,
);
expect(audioRenderer.canRender("https://example.com/image.png")).toBe(
false,
);
expect(audioRenderer.canRender("https://example.com/page")).toBe(false);
expect(audioRenderer.canRender("just some text")).toBe(false);
});
it("rejects non-string values", () => {
expect(audioRenderer.canRender(123)).toBe(false);
expect(audioRenderer.canRender(null)).toBe(false);
expect(audioRenderer.canRender({ url: "test.mp3" })).toBe(false);
});
});

View File

@@ -0,0 +1,133 @@
import React from "react";
import {
OutputRenderer,
OutputMetadata,
DownloadContent,
CopyContent,
} from "../types";
const audioExtensions = [".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"];
const audioMimeTypes = [
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/mp4",
"audio/aac",
"audio/flac",
];
function guessMimeType(url: string): string | null {
if (url.startsWith("data:")) {
const mimeMatch = url.match(/^data:([^;,]+)/);
return mimeMatch?.[1] || null;
}
const extension = url.split(/[?#]/)[0].split(".").pop()?.toLowerCase();
const mimeMap: Record<string, string> = {
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
m4a: "audio/mp4",
aac: "audio/aac",
flac: "audio/flac",
};
return extension ? mimeMap[extension] || null : null;
}
function canRenderAudio(value: unknown, metadata?: OutputMetadata): boolean {
if (typeof value !== "string") return false;
if (
metadata?.type === "audio" ||
(metadata?.mimeType && audioMimeTypes.includes(metadata.mimeType))
) {
return true;
}
if (value.startsWith("data:audio/")) {
return true;
}
if (value.startsWith("http://") || value.startsWith("https://")) {
const cleanURL = value.split(/[?#]/)[0].toLowerCase();
if (audioExtensions.some((ext) => cleanURL.endsWith(ext))) {
return true;
}
}
if (metadata?.filename) {
const cleanName = metadata.filename.toLowerCase();
return audioExtensions.some((ext) => cleanName.endsWith(ext));
}
return false;
}
function renderAudio(
value: unknown,
metadata?: OutputMetadata,
): React.ReactNode {
const audioURL = String(value);
const mimeType =
metadata?.mimeType || guessMimeType(audioURL) || "audio/mpeg";
return (
<div className="group relative">
<audio controls preload="metadata" className="w-full">
<source src={audioURL} type={mimeType} />
Your browser does not support the audio element.
</audio>
</div>
);
}
function getCopyContentAudio(
value: unknown,
_metadata?: OutputMetadata,
): CopyContent | null {
const audioURL = String(value);
return {
mimeType: "text/plain",
data: audioURL,
fallbackText: audioURL,
};
}
const extensionMap: Record<string, string> = {
"audio/mpeg": "mp3",
"audio/wav": "wav",
"audio/ogg": "ogg",
"audio/mp4": "m4a",
"audio/aac": "aac",
"audio/flac": "flac",
};
function getDownloadContentAudio(
value: unknown,
metadata?: OutputMetadata,
): DownloadContent | null {
const audioURL = String(value);
const mimeType =
metadata?.mimeType || guessMimeType(audioURL) || "audio/mpeg";
const ext = extensionMap[mimeType] || "mp3";
return {
data: audioURL,
filename: metadata?.filename || `audio.${ext}`,
mimeType,
};
}
function isConcatenableAudio(): boolean {
return false;
}
export const audioRenderer: OutputRenderer = {
name: "AudioRenderer",
priority: 42,
canRender: canRenderAudio,
render: renderAudio,
getCopyContent: getCopyContentAudio,
getDownloadContent: getDownloadContentAudio,
isConcatenable: isConcatenableAudio,
};

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { linkRenderer } from "./LinkRenderer";
describe("LinkRenderer canRender", () => {
it("detects plain HTTP URLs", () => {
expect(linkRenderer.canRender("https://example.com")).toBe(true);
expect(linkRenderer.canRender("http://example.com/page")).toBe(true);
expect(linkRenderer.canRender("https://example.com/path/to/resource")).toBe(
true,
);
});
it("detects URLs with query params", () => {
expect(linkRenderer.canRender("https://example.com/search?q=test")).toBe(
true,
);
});
it("rejects non-URL strings", () => {
expect(linkRenderer.canRender("just some text")).toBe(false);
expect(linkRenderer.canRender("not a url at all")).toBe(false);
expect(linkRenderer.canRender("")).toBe(false);
});
it("rejects non-string values", () => {
expect(linkRenderer.canRender(123)).toBe(false);
expect(linkRenderer.canRender(null)).toBe(false);
expect(linkRenderer.canRender({ url: "https://example.com" })).toBe(false);
});
it("rejects data URIs (handled by other renderers)", () => {
expect(linkRenderer.canRender("data:image/png;base64,abc")).toBe(false);
});
it("rejects workspace URIs (handled by WorkspaceFileRenderer)", () => {
expect(linkRenderer.canRender("workspace://file-123#image/png")).toBe(
false,
);
});
});

View File

@@ -0,0 +1,78 @@
import React from "react";
import { ArrowSquareOut } from "@phosphor-icons/react";
import {
OutputRenderer,
OutputMetadata,
DownloadContent,
CopyContent,
} from "../types";
function canRenderLink(value: unknown, _metadata?: OutputMetadata): boolean {
if (typeof value !== "string") return false;
const trimmed = value.trim();
return trimmed.startsWith("http://") || trimmed.startsWith("https://");
}
function getDisplayURL(url: string): string {
try {
const parsed = new URL(url);
const path = parsed.pathname === "/" ? "" : parsed.pathname;
const display = parsed.hostname + path;
return display.length > 60 ? display.slice(0, 57) + "..." : display;
} catch {
return url.length > 60 ? url.slice(0, 57) + "..." : url;
}
}
function renderLink(
value: unknown,
_metadata?: OutputMetadata,
): React.ReactNode {
const url = String(value).trim();
const displayText = getDisplayURL(url);
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md text-sm text-blue-600 underline decoration-blue-300 underline-offset-2 transition-colors hover:text-blue-800 hover:decoration-blue-500"
>
<span className="break-all">{displayText}</span>
<ArrowSquareOut size={14} className="flex-shrink-0" />
</a>
);
}
function getCopyContentLink(
value: unknown,
_metadata?: OutputMetadata,
): CopyContent | null {
const url = String(value).trim();
return {
mimeType: "text/plain",
data: url,
fallbackText: url,
};
}
function getDownloadContentLink(
_value: unknown,
_metadata?: OutputMetadata,
): DownloadContent | null {
return null;
}
function isConcatenableLink(): boolean {
return false;
}
export const linkRenderer: OutputRenderer = {
name: "LinkRenderer",
priority: 5,
canRender: canRenderLink,
render: renderLink,
getCopyContent: getCopyContentLink,
getDownloadContent: getDownloadContentLink,
isConcatenable: isConcatenableLink,
};

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { videoRenderer } from "./VideoRenderer";
describe("VideoRenderer canRender", () => {
it("detects direct video file URLs", () => {
expect(videoRenderer.canRender("https://example.com/video.mp4")).toBe(true);
expect(videoRenderer.canRender("https://example.com/video.webm")).toBe(
true,
);
expect(videoRenderer.canRender("https://example.com/video.mov")).toBe(true);
});
it("detects data URIs", () => {
expect(videoRenderer.canRender("data:video/mp4;base64,abc")).toBe(true);
});
it("detects YouTube watch URLs", () => {
expect(
videoRenderer.canRender("https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
).toBe(true);
expect(
videoRenderer.canRender("https://youtube.com/watch?v=dQw4w9WgXcQ"),
).toBe(true);
});
it("detects YouTube short URLs", () => {
expect(videoRenderer.canRender("https://youtu.be/dQw4w9WgXcQ")).toBe(true);
});
it("detects YouTube embed URLs", () => {
expect(
videoRenderer.canRender("https://www.youtube.com/embed/dQw4w9WgXcQ"),
).toBe(true);
});
it("detects Vimeo URLs", () => {
expect(videoRenderer.canRender("https://vimeo.com/123456789")).toBe(true);
expect(videoRenderer.canRender("https://www.vimeo.com/123456789")).toBe(
true,
);
});
it("rejects non-video URLs", () => {
expect(videoRenderer.canRender("https://example.com/page")).toBe(false);
expect(videoRenderer.canRender("https://example.com/image.png")).toBe(
false,
);
expect(videoRenderer.canRender("just some text")).toBe(false);
});
it("handles metadata type override", () => {
expect(videoRenderer.canRender("anything", { type: "video" })).toBe(true);
});
});

View File

@@ -26,7 +26,11 @@ const videoMimeTypes = [
];
function guessMimeType(url: string): string | null {
const extension = url.split(".").pop()?.toLowerCase();
if (url.startsWith("data:")) {
const mimeMatch = url.match(/^data:([^;,]+)/);
return mimeMatch?.[1] || null;
}
const extension = url.split(/[?#]/)[0].split(".").pop()?.toLowerCase();
const mimeMap: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
@@ -39,7 +43,28 @@ function guessMimeType(url: string): string | null {
return extension ? mimeMap[extension] || null : null;
}
const YOUTUBE_REGEX =
/^https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?.*v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
const VIMEO_REGEX = /^https?:\/\/(?:www\.)?vimeo\.com\/(\d+)/;
function getYouTubeVideoID(url: string): string | null {
const match = url.match(YOUTUBE_REGEX);
return match ? match[1] : null;
}
function getVimeoVideoID(url: string): string | null {
const match = url.match(VIMEO_REGEX);
return match ? match[1] : null;
}
function isEmbeddableVideoURL(url: string): boolean {
return getYouTubeVideoID(url) !== null || getVimeoVideoID(url) !== null;
}
function canRenderVideo(value: unknown, metadata?: OutputMetadata): boolean {
if (typeof value !== "string") return false;
if (
metadata?.type === "video" ||
(metadata?.mimeType && videoMimeTypes.includes(metadata.mimeType))
@@ -47,20 +72,25 @@ function canRenderVideo(value: unknown, metadata?: OutputMetadata): boolean {
return true;
}
if (typeof value === "string") {
if (value.startsWith("data:video/")) {
if (value.startsWith("data:video/")) {
return true;
}
if (isEmbeddableVideoURL(value)) {
return true;
}
if (value.startsWith("http://") || value.startsWith("https://")) {
const cleanURL = value.split(/[?#]/)[0].toLowerCase();
if (videoExtensions.some((ext) => cleanURL.endsWith(ext))) {
return true;
}
}
if (value.startsWith("http://") || value.startsWith("https://")) {
return videoExtensions.some((ext) => value.toLowerCase().includes(ext));
}
if (metadata?.filename) {
return videoExtensions.some((ext) =>
metadata.filename!.toLowerCase().endsWith(ext),
);
}
if (metadata?.filename) {
return videoExtensions.some((ext) =>
metadata.filename!.toLowerCase().endsWith(ext),
);
}
return false;
@@ -72,6 +102,38 @@ function renderVideo(
): React.ReactNode {
const videoUrl = String(value);
const youtubeID = getYouTubeVideoID(videoUrl);
if (youtubeID) {
return (
<div className="group relative aspect-video w-full">
<iframe
className="h-full w-full rounded-md border border-gray-200"
src={`https://www.youtube.com/embed/${youtubeID}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
sandbox="allow-scripts allow-same-origin allow-presentation allow-fullscreen allow-popups"
title="YouTube video"
/>
</div>
);
}
const vimeoID = getVimeoVideoID(videoUrl);
if (vimeoID) {
return (
<div className="group relative aspect-video w-full">
<iframe
className="h-full w-full rounded-md border border-gray-200"
src={`https://player.vimeo.com/video/${vimeoID}`}
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
sandbox="allow-scripts allow-same-origin allow-presentation allow-fullscreen allow-popups"
title="Vimeo video"
/>
</div>
);
}
return (
<div className="group relative">
<video
@@ -92,6 +154,15 @@ function getCopyContentVideo(
): CopyContent | null {
const videoUrl = String(value);
// For embeddable URLs, just copy the URL text
if (isEmbeddableVideoURL(videoUrl)) {
return {
mimeType: "text/plain",
data: videoUrl,
fallbackText: videoUrl,
};
}
if (videoUrl.startsWith("data:")) {
const mimeMatch = videoUrl.match(/data:([^;]+)/);
const mimeType = mimeMatch?.[1] || "video/mp4";
@@ -123,6 +194,14 @@ function getDownloadContentVideo(
): DownloadContent | null {
const videoUrl = String(value);
if (isEmbeddableVideoURL(videoUrl)) {
return {
data: new Blob([videoUrl], { type: "text/plain" }),
filename: metadata?.filename || "video-url.txt",
mimeType: "text/plain",
};
}
if (videoUrl.startsWith("data:")) {
const [mimeInfo, base64Data] = videoUrl.split(",");
const mimeType = mimeInfo.match(/data:([^;]+)/)?.[1] || "video/mp4";