mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(bunny-cdn): refactor CDN hostname resolution and update asset URLs for improved flexibility
This commit is contained in:
+9
-6
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Version {
|
||||
@@ -99,8 +100,6 @@ const isSafeUrl = (url: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||
|
||||
export default function CompareVersionsPageClient({ projectId, videoId }: { projectId: string; videoId: string }) {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -867,6 +866,7 @@ function BunnyPanel({
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||
const [portraitFrameWidth, setPortraitFrameWidth] = useState<number>(0);
|
||||
const [isPortraitSource, setIsPortraitSource] = useState(false);
|
||||
|
||||
@@ -980,8 +980,11 @@ function BunnyPanel({
|
||||
const onPlay = () => { isPlaying = true; };
|
||||
const onPause = () => { isPlaying = false; };
|
||||
const onEnded = () => { isPlaying = false; };
|
||||
const hlsUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/playlist.m3u8`;
|
||||
const originalUrl = `https://${BUNNY_PULL_ZONE_HOSTNAME}/${version.videoId}/original`;
|
||||
if (!bunnyCdnHostname) {
|
||||
return;
|
||||
}
|
||||
const hlsUrl = `https://${bunnyCdnHostname}/${version.videoId}/playlist.m3u8`;
|
||||
const originalUrl = `https://${bunnyCdnHostname}/${version.videoId}/original`;
|
||||
const activateOriginalFallback = (): void => {
|
||||
sourceMode = 'original';
|
||||
clearRetryTimer();
|
||||
@@ -1088,7 +1091,7 @@ function BunnyPanel({
|
||||
onUnregister(version.id);
|
||||
adapter.destroy();
|
||||
};
|
||||
}, [version.id, version.videoId, onRegister, onUnregister]);
|
||||
}, [version.id, version.videoId, onRegister, onUnregister, bunnyCdnHostname]);
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="relative w-full h-full group flex items-center justify-center bg-black">
|
||||
|
||||
@@ -12,10 +12,12 @@ import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import * as tus from 'tus-js-client';
|
||||
|
||||
export default function NewVideoPageClient({ projectId }: { projectId: string }) {
|
||||
const router = useRouter();
|
||||
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFetchingMeta, setIsFetchingMeta] = useState(false);
|
||||
@@ -301,7 +303,9 @@ export default function NewVideoPageClient({ projectId }: { projectId: string })
|
||||
finalVideoId = bunnyData.videoId;
|
||||
// Bunny will generate thumbnails automatically after processing.
|
||||
// We'll just provide the standard CDN thumbnail URL format as fallback.
|
||||
finalThumbnailUrl = `https://vz-thumbnail.b-cdn.net/${bunnyData.videoId}/thumbnail.jpg`;
|
||||
finalThumbnailUrl = bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${bunnyData.videoId}/thumbnail.jpg`
|
||||
: null;
|
||||
}
|
||||
|
||||
// Final POST to our database
|
||||
|
||||
@@ -4,6 +4,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { DownloadEgressSource } from '@prisma/client';
|
||||
|
||||
@@ -17,7 +18,6 @@ type BunnyDownloadSource = {
|
||||
|
||||
const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240];
|
||||
const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
|
||||
const DEFAULT_BUNNY_CDN_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||
const BUNNY_MAX_PROBE_CANDIDATES = 4;
|
||||
const BUNNY_SOURCE_RESOLUTION_CACHE_TTL_MS = 60 * 1000;
|
||||
const BUNNY_REMOTE_FETCH_TIMEOUT_MS = 8 * 1000;
|
||||
@@ -65,20 +65,14 @@ function buildContentDisposition(fileNameWithExt: string): string {
|
||||
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
function resolveBunnyCdnHostname(): string {
|
||||
const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
|
||||
if (!raw) return DEFAULT_BUNNY_CDN_HOSTNAME;
|
||||
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return url.hostname || DEFAULT_BUNNY_CDN_HOSTNAME;
|
||||
} catch {
|
||||
return raw.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_CDN_HOSTNAME;
|
||||
}
|
||||
function resolveBunnyCdnHostname(): string | null {
|
||||
return resolveServerBunnyCdnHostname();
|
||||
}
|
||||
|
||||
function buildBunnyOriginalUrl(videoId: string): string {
|
||||
return `https://${resolveBunnyCdnHostname()}/${videoId}/original`;
|
||||
const hostname = resolveBunnyCdnHostname();
|
||||
if (!hostname) return '';
|
||||
return `https://${hostname}/${videoId}/original`;
|
||||
}
|
||||
|
||||
function buildBunnySourceCacheKey(
|
||||
@@ -140,6 +134,7 @@ async function isRemoteFileAvailable(url: string): Promise<boolean> {
|
||||
|
||||
async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
|
||||
const hostname = resolveBunnyCdnHostname();
|
||||
if (!hostname) return '';
|
||||
const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`;
|
||||
|
||||
let playlistHeights: number[] = [];
|
||||
@@ -172,6 +167,7 @@ async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
|
||||
|
||||
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
|
||||
const originalUrl = buildBunnyOriginalUrl(videoId);
|
||||
if (!originalUrl) return null;
|
||||
if (await isRemoteFileAvailable(originalUrl)) {
|
||||
return {
|
||||
sourceType: 'original',
|
||||
@@ -184,8 +180,17 @@ async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloa
|
||||
}
|
||||
|
||||
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> {
|
||||
const hostname = resolveBunnyCdnHostname();
|
||||
if (!hostname) {
|
||||
return {
|
||||
sourceType: 'compressed',
|
||||
quality: null,
|
||||
url: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof requestedQuality === 'number' && Number.isFinite(requestedQuality) && requestedQuality > 0) {
|
||||
const requestedUrl = `https://${resolveBunnyCdnHostname()}/${videoId}/play_${requestedQuality}p.mp4`;
|
||||
const requestedUrl = `https://${hostname}/${videoId}/play_${requestedQuality}p.mp4`;
|
||||
if (await isRemoteFileAvailable(requestedUrl)) {
|
||||
return {
|
||||
sourceType: 'compressed',
|
||||
@@ -196,6 +201,13 @@ async function resolveBunnyCompressedSource(videoId: string, requestedQuality: n
|
||||
}
|
||||
|
||||
const fallbackUrl = await resolveHighestBunnyMp4Url(videoId);
|
||||
if (!fallbackUrl) {
|
||||
return {
|
||||
sourceType: 'compressed',
|
||||
quality: null,
|
||||
url: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sourceType: 'compressed',
|
||||
@@ -209,6 +221,8 @@ async function resolveBunnyDownloadSource(
|
||||
requestedQuality: number | null,
|
||||
sourcePreference: BunnyDownloadSourcePreference
|
||||
): Promise<BunnyDownloadSource | null> {
|
||||
if (!resolveBunnyCdnHostname()) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const cacheKey = buildBunnySourceCacheKey(videoId, requestedQuality, sourcePreference);
|
||||
const cached = getCachedBunnyDownloadSource(cacheKey, now);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { deriveGuestUploadContext, verifyGuestUploadToken } from '@/lib/guest-up
|
||||
import { ensureGuestIdentityFromRequest, setGuestIdentityCookie } from '@/lib/guest-identity';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { validateUrl, validateOptionalUrl } from '@/lib/validation';
|
||||
import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import {
|
||||
SAFE_BUNNY_VIDEO_ID,
|
||||
SAFE_IMAGE_PROXY_PATH,
|
||||
@@ -26,11 +27,6 @@ type RouteParams = { params: Promise<{ videoId: string }> };
|
||||
const UNATTACHED_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
||||
const ASSET_LIST_DEFAULT_LIMIT = 40;
|
||||
const ASSET_LIST_MAX_LIMIT = 100;
|
||||
const BUNNY_ALLOWED_THUMBNAIL_HOSTS = new Set([
|
||||
'iframe.mediadelivery.net',
|
||||
'video.bunnycdn.com',
|
||||
'vz-965f4f4a-fc1.b-cdn.net',
|
||||
]);
|
||||
const YOUTUBE_TITLE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
type AssetWithViewerFields = {
|
||||
@@ -62,10 +58,19 @@ type YouTubeTitleCacheRecord = {
|
||||
const youtubeTitleCache = new Map<string, YouTubeTitleCacheRecord>();
|
||||
|
||||
function isAllowedBunnyMediaUrl(url: string): boolean {
|
||||
const allowedHosts = new Set<string>([
|
||||
'iframe.mediadelivery.net',
|
||||
'video.bunnycdn.com',
|
||||
]);
|
||||
const bunnyCdnHostname = resolveServerBunnyCdnHostname();
|
||||
if (bunnyCdnHostname) {
|
||||
allowedHosts.add(bunnyCdnHostname);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:') return false;
|
||||
return BUNNY_ALLOWED_THUMBNAIL_HOSTS.has(parsed.hostname);
|
||||
return allowedHosts.has(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -330,7 +335,10 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
displayName = sanitizeAssetDisplayName(requestedDisplayName, `Bunny ${providerVideoId}`);
|
||||
if (!thumbnailUrl) {
|
||||
thumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${providerVideoId}/thumbnail.jpg`;
|
||||
const bunnyCdnHostname = resolveServerBunnyCdnHostname();
|
||||
if (bunnyCdnHostname) {
|
||||
thumbnailUrl = `https://${bunnyCdnHostname}/${providerVideoId}/thumbnail.jpg`;
|
||||
}
|
||||
}
|
||||
kind = 'VIDEO';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { parseVideoUrl, fetchVideoMetadata, getThumbnailUrl, type VideoSource } from '@/lib/video-providers';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
|
||||
interface VideoCardProps {
|
||||
video: {
|
||||
@@ -86,6 +87,20 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
|
||||
// Delete dialog
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||
const resolvedThumbnailUrl = useMemo(() => {
|
||||
if (!video.thumbnailUrl) return '';
|
||||
try {
|
||||
const parsed = new URL(video.thumbnailUrl);
|
||||
if (parsed.hostname === 'vz-thumbnail.b-cdn.net' && bunnyCdnHostname) {
|
||||
parsed.hostname = bunnyCdnHostname;
|
||||
return parsed.toString();
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return video.thumbnailUrl;
|
||||
}
|
||||
}, [video.thumbnailUrl, bunnyCdnHostname]);
|
||||
|
||||
const handleEdit = async () => {
|
||||
setIsSaving(true);
|
||||
@@ -199,9 +214,10 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
|
||||
<span className="text-[11px] text-muted-foreground/90">Video may already be playable</span>
|
||||
</div>
|
||||
) : (
|
||||
resolvedThumbnailUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`${video.thumbnailUrl?.replace('vz-thumbnail.b-cdn.net', 'vz-965f4f4a-fc1.b-cdn.net')}${retryKey ? `?t=${retryKey}` : ''}`}
|
||||
src={`${resolvedThumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}`}
|
||||
alt={video.title}
|
||||
className="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
onError={() => {
|
||||
@@ -213,6 +229,11 @@ export function VideoCard({ video, projectId, canManage, onDeleted }: VideoCardP
|
||||
}, 10000);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted/80 text-xs text-muted-foreground font-medium">
|
||||
Thumbnail unavailable
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!imgError && (
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
|
||||
type ProjectOption = {
|
||||
id: string;
|
||||
@@ -89,6 +90,7 @@ export function VideoDragDropUploader({
|
||||
const hasLoadedProjectsRef = useRef(false);
|
||||
|
||||
const needsProjectSelection = !fixedProjectId;
|
||||
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||
|
||||
const projectsById = useMemo(() => {
|
||||
return new Map(projects.map((project) => [project.id, project.name]));
|
||||
@@ -296,7 +298,9 @@ export function VideoDragDropUploader({
|
||||
videoUrl: `https://iframe.mediadelivery.net/embed/${initPayload.data.libraryId}/${initPayload.data.videoId}`,
|
||||
providerId: 'bunny',
|
||||
videoId: initPayload.data.videoId,
|
||||
thumbnailUrl: `https://vz-thumbnail.b-cdn.net/${initPayload.data.videoId}/thumbnail.jpg`,
|
||||
thumbnailUrl: bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${initPayload.data.videoId}/thumbnail.jpg`
|
||||
: null,
|
||||
duration: null,
|
||||
uploadToken,
|
||||
}),
|
||||
@@ -341,7 +345,7 @@ export function VideoDragDropUploader({
|
||||
setIsUploading(false);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to upload video');
|
||||
}
|
||||
}, [cleanupUploadState, projectsById, router]);
|
||||
}, [bunnyCdnHostname, cleanupUploadState, projectsById, router]);
|
||||
|
||||
const handleDropFile = useCallback((file: File) => {
|
||||
if (!canUpload) {
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
} from '@/components/video-page/types';
|
||||
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
||||
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
@@ -59,7 +60,6 @@ function formatBunnyQualityLabel(level: { height?: number; bitrate?: number }, i
|
||||
}
|
||||
|
||||
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||
|
||||
export type VideoPageMode = 'dashboard' | 'watch';
|
||||
|
||||
@@ -255,6 +255,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
}, [video?.versions, activeVersionId]);
|
||||
const activeProviderId = activeVersion?.providerId;
|
||||
const activeVersionDuration = activeVersion?.duration;
|
||||
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||
const embedUrl = useMemo(() => {
|
||||
if (!activeVersion) return '';
|
||||
if (activeVersion.providerId === 'youtube') {
|
||||
@@ -264,7 +265,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
return `${base}&origin=${encodeURIComponent(origin)}`;
|
||||
}
|
||||
if (activeVersion.providerId === 'bunny') {
|
||||
return `https://${BUNNY_PULL_ZONE_HOSTNAME}/${activeVersion.videoId}/playlist.m3u8`;
|
||||
if (!bunnyCdnHostname) return '';
|
||||
return `https://${bunnyCdnHostname}/${activeVersion.videoId}/playlist.m3u8`;
|
||||
}
|
||||
try {
|
||||
const url = new URL(activeVersion.originalUrl);
|
||||
@@ -275,7 +277,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [activeVersion]);
|
||||
}, [activeVersion, bunnyCdnHostname]);
|
||||
|
||||
const {
|
||||
isReady,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { BunnyPreviewPlayer, type BunnyPreviewPlayerHandle } from '@/components/
|
||||
import { AssetListSection } from '@/components/video-page/asset-list-section';
|
||||
import type { VideoAsset } from '@/components/video-page/types';
|
||||
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
|
||||
interface AssetsPaneProps {
|
||||
videoId: string;
|
||||
@@ -82,6 +83,7 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null);
|
||||
const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null);
|
||||
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||
const [focusedAssetId, setFocusedAssetId] = useState<string | null>(null);
|
||||
const bunnyPreviewPlayerRef = useRef<BunnyPreviewPlayerHandle | null>(null);
|
||||
const youtubeIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
@@ -369,7 +371,9 @@ export const AssetsPane = memo(function AssetsPane({
|
||||
});
|
||||
|
||||
const sourceUrl = `https://iframe.mediadelivery.net/embed/${initData.libraryId}/${initData.videoId}`;
|
||||
const thumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${initData.videoId}/thumbnail.jpg`;
|
||||
const thumbnailUrl = bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${initData.videoId}/thumbnail.jpg`
|
||||
: undefined;
|
||||
const createdAsset = await createAsset({
|
||||
provider: 'BUNNY',
|
||||
sourceUrl,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BunnyPlaybackState, BunnyQualityOption } from '@/components/video-page/types';
|
||||
|
||||
@@ -28,17 +29,6 @@ export interface BunnyPreviewPlayerHandle {
|
||||
|
||||
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
|
||||
function resolveBunnyCdnHostname(): string | null {
|
||||
const configured = process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
|
||||
if (!configured) return null;
|
||||
try {
|
||||
const parsed = new URL(configured);
|
||||
return parsed.hostname || null;
|
||||
} catch {
|
||||
return configured.replace(/^https?:\/\//, '').replace(/\/+$/, '') || null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(value: number): string {
|
||||
if (!Number.isFinite(value) || value < 0) return '0:00';
|
||||
const total = Math.floor(value);
|
||||
@@ -78,7 +68,7 @@ export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPrev
|
||||
const [selectedQualityLevel, setSelectedQualityLevel] = useState<number>(-1);
|
||||
const [bunnySourcePreference, setBunnySourcePreference] = useState<'auto' | 'original'>('auto');
|
||||
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
|
||||
const bunnyCdnHostname = useMemo(() => resolveBunnyCdnHostname(), []);
|
||||
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||
|
||||
const playlistUrl = useMemo(() => {
|
||||
if (!providerVideoId || !bunnyCdnHostname) return null;
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { BunnyDownloadPreference, Comment, DownloadTarget, Version, VideoData } from '@/components/video-page/types';
|
||||
|
||||
const BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
|
||||
function sanitizeDownloadFileName(value: string): string {
|
||||
return value
|
||||
@@ -14,17 +13,9 @@ function sanitizeDownloadFileName(value: string): string {
|
||||
}
|
||||
|
||||
function getAllowedHosts() {
|
||||
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
||||
return [
|
||||
BUNNY_PULL_ZONE_HOSTNAME,
|
||||
...(process.env.NEXT_PUBLIC_BUNNY_CDN_URL
|
||||
? (() => {
|
||||
try {
|
||||
return [new URL(process.env.NEXT_PUBLIC_BUNNY_CDN_URL).hostname];
|
||||
} catch {
|
||||
return [process.env.NEXT_PUBLIC_BUNNY_CDN_URL.replace(/^https?:\/\//, '').replace(/\/+$/, '')];
|
||||
}
|
||||
})()
|
||||
: []),
|
||||
...(bunnyCdnHostname ? [bunnyCdnHostname] : []),
|
||||
...(process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ?? '').split(','),
|
||||
]
|
||||
.map((host) => host.trim().toLowerCase())
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from 'sonner';
|
||||
import * as tus from 'tus-js-client';
|
||||
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
|
||||
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
|
||||
interface UseVersionActionsParams extends VersionActionsConfig {
|
||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||
@@ -33,6 +34,7 @@ export function useVersionActions({
|
||||
const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false);
|
||||
const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
|
||||
const [isDeletingVersion, setIsDeletingVersion] = useState(false);
|
||||
const bunnyCdnHostname = resolvePublicBunnyCdnHostname();
|
||||
|
||||
const handleNewVersionUrlChange = (url: string) => {
|
||||
setNewVersionUrl(url);
|
||||
@@ -126,7 +128,9 @@ export function useVersionActions({
|
||||
finalVideoUrl = `https://iframe.mediadelivery.net/embed/${libraryId}/${bunnyVideoId}`;
|
||||
finalProviderId = 'bunny';
|
||||
finalProviderVideoId = bunnyVideoId;
|
||||
finalThumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${bunnyVideoId}/thumbnail.jpg`;
|
||||
finalThumbnailUrl = bunnyCdnHostname
|
||||
? `https://${bunnyCdnHostname}/${bunnyVideoId}/thumbnail.jpg`
|
||||
: null;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
function normalizeBunnyCdnHostname(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return parsed.hostname || null;
|
||||
} catch {
|
||||
return trimmed.replace(/^https?:\/\//, '').replace(/\/+$/, '') || null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveServerBunnyCdnHostname(): string | null {
|
||||
return normalizeBunnyCdnHostname(process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL);
|
||||
}
|
||||
|
||||
export function resolvePublicBunnyCdnHostname(): string | null {
|
||||
return normalizeBunnyCdnHostname(process.env.NEXT_PUBLIC_BUNNY_CDN_URL);
|
||||
}
|
||||
+28
-13
@@ -1,3 +1,5 @@
|
||||
import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
|
||||
type BunnyDownloadSourcePreference = 'auto' | 'original' | 'compressed';
|
||||
|
||||
export type BunnyDownloadSource = {
|
||||
@@ -6,7 +8,6 @@ export type BunnyDownloadSource = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
const DEFAULT_BUNNY_CDN_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||
const BUNNY_DOWNLOAD_FALLBACK_HEIGHTS = [2160, 1440, 1080, 720, 480, 360, 240];
|
||||
const BUNNY_ALLOWED_QUALITIES = new Set(BUNNY_DOWNLOAD_FALLBACK_HEIGHTS);
|
||||
const BUNNY_MAX_PROBE_CANDIDATES = 4;
|
||||
@@ -20,16 +21,8 @@ type BunnyDownloadSourceCacheRecord = {
|
||||
|
||||
const bunnyDownloadSourceCache = new Map<string, BunnyDownloadSourceCacheRecord>();
|
||||
|
||||
export function resolveBunnyCdnHostname(): string {
|
||||
const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
|
||||
if (!raw) return DEFAULT_BUNNY_CDN_HOSTNAME;
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return parsed.hostname || DEFAULT_BUNNY_CDN_HOSTNAME;
|
||||
} catch {
|
||||
return raw.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_CDN_HOSTNAME;
|
||||
}
|
||||
export function resolveBunnyCdnHostname(): string | null {
|
||||
return resolveServerBunnyCdnHostname();
|
||||
}
|
||||
|
||||
export async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
||||
@@ -63,7 +56,9 @@ async function isRemoteFileAvailable(url: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
function buildBunnyOriginalUrl(videoId: string): string {
|
||||
return `https://${resolveBunnyCdnHostname()}/${videoId}/original`;
|
||||
const hostname = resolveBunnyCdnHostname();
|
||||
if (!hostname) return '';
|
||||
return `https://${hostname}/${videoId}/original`;
|
||||
}
|
||||
|
||||
function extractHeightFromBunnyMp4Url(url: string): number | null {
|
||||
@@ -75,6 +70,7 @@ function extractHeightFromBunnyMp4Url(url: string): number | null {
|
||||
|
||||
async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
|
||||
const hostname = resolveBunnyCdnHostname();
|
||||
if (!hostname) return '';
|
||||
const playlistUrl = `https://${hostname}/${videoId}/playlist.m3u8`;
|
||||
|
||||
let playlistHeights: number[] = [];
|
||||
@@ -106,6 +102,7 @@ async function resolveHighestBunnyMp4Url(videoId: string): Promise<string> {
|
||||
|
||||
async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloadSource | null> {
|
||||
const originalUrl = buildBunnyOriginalUrl(videoId);
|
||||
if (!originalUrl) return null;
|
||||
if (await isRemoteFileAvailable(originalUrl)) {
|
||||
return {
|
||||
sourceType: 'original',
|
||||
@@ -118,8 +115,17 @@ async function resolveBunnyOriginalSource(videoId: string): Promise<BunnyDownloa
|
||||
}
|
||||
|
||||
async function resolveBunnyCompressedSource(videoId: string, requestedQuality: number | null): Promise<BunnyDownloadSource> {
|
||||
const hostname = resolveBunnyCdnHostname();
|
||||
if (!hostname) {
|
||||
return {
|
||||
sourceType: 'compressed',
|
||||
quality: null,
|
||||
url: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof requestedQuality === 'number' && Number.isFinite(requestedQuality) && requestedQuality > 0) {
|
||||
const requestedUrl = `https://${resolveBunnyCdnHostname()}/${videoId}/play_${requestedQuality}p.mp4`;
|
||||
const requestedUrl = `https://${hostname}/${videoId}/play_${requestedQuality}p.mp4`;
|
||||
if (await isRemoteFileAvailable(requestedUrl)) {
|
||||
return {
|
||||
sourceType: 'compressed',
|
||||
@@ -130,6 +136,13 @@ async function resolveBunnyCompressedSource(videoId: string, requestedQuality: n
|
||||
}
|
||||
|
||||
const fallbackUrl = await resolveHighestBunnyMp4Url(videoId);
|
||||
if (!fallbackUrl) {
|
||||
return {
|
||||
sourceType: 'compressed',
|
||||
quality: null,
|
||||
url: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
sourceType: 'compressed',
|
||||
quality: extractHeightFromBunnyMp4Url(fallbackUrl),
|
||||
@@ -163,6 +176,8 @@ export async function resolveBunnyDownloadSource(
|
||||
requestedQuality: number | null,
|
||||
preference: BunnyDownloadSourcePreference
|
||||
): Promise<BunnyDownloadSource | null> {
|
||||
if (!resolveBunnyCdnHostname()) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const cacheKey = buildSourceCacheKey(videoId, requestedQuality, preference);
|
||||
const cached = getCachedSource(cacheKey, now);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { VideoProvider, VideoMetadata, EmbedOptions } from './types';
|
||||
import { getCachedMetadata, setCachedMetadata } from './metadata-cache';
|
||||
import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
|
||||
// Bunny Stream URL patterns
|
||||
// e.g. https://iframe.mediadelivery.net/play/libraryId/videoId
|
||||
@@ -44,11 +45,9 @@ export const bunnyProvider: VideoProvider = {
|
||||
},
|
||||
|
||||
getThumbnailUrl(videoId: string): string {
|
||||
// Bunny stream thumbnails: https://vz-uuid.b-cdn.net/{videoId}/thumbnail.jpg
|
||||
// Since we don't have the b-cdn pull zone readily available in pure abstract,
|
||||
// we should rely on fetching metadata for actual thumbnails, OR construct via API
|
||||
// Actually, Bunny's public thumbnail format is:
|
||||
return `https://vz-965f4f4a-fc1.b-cdn.net/${videoId}/thumbnail.jpg`; // Fallback approximate
|
||||
const bunnyCdnHostname = resolveServerBunnyCdnHostname();
|
||||
if (!bunnyCdnHostname) return '';
|
||||
return `https://${bunnyCdnHostname}/${videoId}/thumbnail.jpg`;
|
||||
},
|
||||
|
||||
async getMetadata(videoId: string): Promise<VideoMetadata> {
|
||||
|
||||
+20
-5
@@ -1,14 +1,29 @@
|
||||
import type { NextConfig } from "next";
|
||||
import type { RemotePattern } from "next/dist/shared/lib/image-config";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
function resolveBunnyCdnHostname(): string | null {
|
||||
const raw = process.env.BUNNY_CDN_URL || process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return parsed.hostname || null;
|
||||
} catch {
|
||||
return raw.replace(/^https?:\/\//, '').replace(/\/+$/, '') || null;
|
||||
}
|
||||
}
|
||||
|
||||
const bunnyCdnHostname = resolveBunnyCdnHostname();
|
||||
const remotePatterns: RemotePattern[] = [
|
||||
{ protocol: 'https', hostname: 'img.youtube.com' },
|
||||
{ protocol: 'https', hostname: 'i.ytimg.com' },
|
||||
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
||||
{ protocol: 'https', hostname: 'vz-thumbnail.b-cdn.net' },
|
||||
{ protocol: 'https', hostname: 'vz-965f4f4a-fc1.b-cdn.net' },
|
||||
],
|
||||
...(bunnyCdnHostname ? [{ protocol: 'https' as const, hostname: bunnyCdnHostname }] : []),
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns,
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
},
|
||||
experimental: {
|
||||
|
||||
Reference in New Issue
Block a user