mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
@@ -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 (
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user