mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Project/selected downloads now include only videos by default. Add an "Include assets" checkbox toggle to both download dropdowns (default off) that adds b-rolls and other attached assets to the download when enabled. - buildProjectDownloadManifest gains an includeAssets option (default false). - Download route reads ?assets=1 and passes it through.
316 lines
9.7 KiB
TypeScript
316 lines
9.7 KiB
TypeScript
import { VideoAssetProvider } from '@prisma/client';
|
|
import {
|
|
extractAudioFileNameFromProxyUrl,
|
|
extractImageFileNameFromProxyUrl,
|
|
extractVideoFileNameFromProxyUrl,
|
|
sanitizeAssetDisplayName,
|
|
} from '@/lib/video-assets';
|
|
|
|
const DEFAULT_MAX_FILES = 250;
|
|
const DEFAULT_MAX_BYTES = 20 * 1024 * 1024 * 1024; // 20 GiB
|
|
|
|
export type ProjectDownloadAccess = {
|
|
hasAccess: boolean;
|
|
canEdit: boolean;
|
|
};
|
|
|
|
export type ProjectDownloadTarget = {
|
|
id: string;
|
|
name: string;
|
|
allowDownloads: boolean;
|
|
workspaceId: string;
|
|
workspaceOwnerId: string;
|
|
};
|
|
|
|
export type ProjectDownloadManifestFile = {
|
|
fileName: string;
|
|
url: string;
|
|
sizeBytes: number | null;
|
|
};
|
|
|
|
export type ProjectDownloadManifest = {
|
|
projectName: string;
|
|
files: ProjectDownloadManifestFile[];
|
|
totalFiles: number;
|
|
totalBytes: string | null;
|
|
};
|
|
|
|
export function getProjectDownloadLimits(): { maxFiles: number; maxBytes: bigint } {
|
|
const maxFilesRaw = Number(process.env.OPENFRAME_PROJECT_DOWNLOAD_MAX_FILES ?? DEFAULT_MAX_FILES);
|
|
const maxFiles =
|
|
Number.isSafeInteger(maxFilesRaw) && maxFilesRaw > 0 ? maxFilesRaw : DEFAULT_MAX_FILES;
|
|
|
|
const maxBytesRaw = Number(process.env.OPENFRAME_PROJECT_DOWNLOAD_MAX_BYTES ?? DEFAULT_MAX_BYTES);
|
|
const maxBytes =
|
|
Number.isSafeInteger(maxBytesRaw) && maxBytesRaw > 0
|
|
? BigInt(maxBytesRaw)
|
|
: BigInt(DEFAULT_MAX_BYTES);
|
|
|
|
return { maxFiles, maxBytes };
|
|
}
|
|
|
|
export function canDownloadProjectMedia(
|
|
project: Pick<ProjectDownloadTarget, 'allowDownloads'>,
|
|
access: ProjectDownloadAccess
|
|
): boolean {
|
|
if (!access.hasAccess) return false;
|
|
if (access.canEdit) return true;
|
|
return project.allowDownloads;
|
|
}
|
|
|
|
function sanitizeFileName(value: string): string {
|
|
const sanitized = value
|
|
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
return sanitized.length > 0 ? sanitized : 'file';
|
|
}
|
|
|
|
function getAllowedDirectHosts(): string[] {
|
|
return (process.env.NEXT_PUBLIC_DIRECT_DOWNLOAD_ALLOWED_HOSTS ?? '')
|
|
.split(',')
|
|
.map((host) => host.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function getSafeDirectDownloadUrl(rawUrl: string): string | null {
|
|
try {
|
|
const parsed = new URL(rawUrl);
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
|
const allowedHosts = getAllowedDirectHosts();
|
|
if (allowedHosts.length === 0) return null;
|
|
if (!allowedHosts.includes(parsed.hostname.toLowerCase())) return null;
|
|
return parsed.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extensionFromUrl(url: string, fallback: string): string {
|
|
const withoutQuery = url.split('?')[0] ?? url;
|
|
const ext = withoutQuery.includes('.') ? withoutQuery.slice(withoutQuery.lastIndexOf('.')) : '';
|
|
return ext || fallback;
|
|
}
|
|
|
|
type VersionRow = {
|
|
id: string;
|
|
versionNumber: number;
|
|
versionLabel: string | null;
|
|
providerId: string;
|
|
videoId: string;
|
|
originalUrl: string;
|
|
sizeBytes: bigint;
|
|
};
|
|
|
|
type AssetRow = {
|
|
id: string;
|
|
provider: VideoAssetProvider;
|
|
displayName: string;
|
|
sourceUrl: string;
|
|
providerVideoId: string | null;
|
|
sizeBytes: bigint;
|
|
};
|
|
|
|
type VideoRow = {
|
|
id: string;
|
|
title: string;
|
|
position: number;
|
|
versions: VersionRow[];
|
|
assets: AssetRow[];
|
|
};
|
|
|
|
function selectLatestVersion(versions: VersionRow[]): VersionRow[] {
|
|
if (versions.length === 0) return [];
|
|
let latest = versions[0]!;
|
|
for (const version of versions) {
|
|
if (version.versionNumber > latest.versionNumber) latest = version;
|
|
}
|
|
return [latest];
|
|
}
|
|
|
|
function makeUniqueName(baseName: string, usedNames: Set<string>): string {
|
|
if (!usedNames.has(baseName)) {
|
|
usedNames.add(baseName);
|
|
return baseName;
|
|
}
|
|
|
|
const dotIndex = baseName.lastIndexOf('.');
|
|
const stem = dotIndex > 0 ? baseName.slice(0, dotIndex) : baseName;
|
|
const ext = dotIndex > 0 ? baseName.slice(dotIndex) : '';
|
|
|
|
let counter = 2;
|
|
while (usedNames.has(`${stem}-${counter}${ext}`)) {
|
|
counter += 1;
|
|
}
|
|
const unique = `${stem}-${counter}${ext}`;
|
|
usedNames.add(unique);
|
|
return unique;
|
|
}
|
|
|
|
function buildVersionFileName(videoIndex: number, videoTitle: string, version: VersionRow): string {
|
|
const label = version.versionLabel?.trim() || `v${version.versionNumber}`;
|
|
const stem = sanitizeFileName(`${String(videoIndex).padStart(2, '0')}-${videoTitle}-${label}`);
|
|
const ext = extensionFromUrl(version.originalUrl, '.mp4');
|
|
return `${stem}${ext}`;
|
|
}
|
|
|
|
function buildAssetFileName(videoIndex: number, videoTitle: string, asset: AssetRow): string {
|
|
const displayName = sanitizeAssetDisplayName(asset.displayName, 'asset');
|
|
const stem = sanitizeFileName(
|
|
`${String(videoIndex).padStart(2, '0')}-${videoTitle}-asset-${displayName}`
|
|
);
|
|
|
|
if (asset.provider === VideoAssetProvider.R2_IMAGE) {
|
|
const fileName = extractImageFileNameFromProxyUrl(asset.sourceUrl);
|
|
const ext = fileName?.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.png';
|
|
return `${stem}${ext}`;
|
|
}
|
|
if (asset.provider === VideoAssetProvider.R2_AUDIO) {
|
|
const fileName = extractAudioFileNameFromProxyUrl(asset.sourceUrl);
|
|
const ext = fileName?.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.webm';
|
|
return `${stem}${ext}`;
|
|
}
|
|
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
|
|
const fileName = extractVideoFileNameFromProxyUrl(asset.sourceUrl);
|
|
const ext = fileName?.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.mp4';
|
|
return `${stem}${ext}`;
|
|
}
|
|
if (asset.provider === VideoAssetProvider.BUNNY) {
|
|
return `${stem}.mp4`;
|
|
}
|
|
|
|
return `${stem}.bin`;
|
|
}
|
|
|
|
function versionDownloadUrl(version: VersionRow): string | null {
|
|
if (version.providerId === 'bunny' && version.videoId) {
|
|
// Always fetch the original (uncompressed) file for bulk/project downloads so
|
|
// quality never drops. 'auto' could silently fall back to a compressed MP4.
|
|
return `/api/versions/${version.id}/download?source=original`;
|
|
}
|
|
if (version.providerId === 'r2') {
|
|
if (version.originalUrl.startsWith('/api/upload/video/')) {
|
|
return version.originalUrl;
|
|
}
|
|
const fileName = extractVideoFileNameFromProxyUrl(version.originalUrl);
|
|
if (fileName) return `/api/upload/video/${fileName}`;
|
|
}
|
|
if (version.providerId === 'direct') {
|
|
return getSafeDirectDownloadUrl(version.originalUrl);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function assetDownloadUrl(videoId: string, asset: AssetRow): string | null {
|
|
if (asset.provider === VideoAssetProvider.YOUTUBE) return null;
|
|
if (
|
|
asset.provider === VideoAssetProvider.R2_IMAGE ||
|
|
asset.provider === VideoAssetProvider.R2_AUDIO ||
|
|
asset.provider === VideoAssetProvider.R2_VIDEO ||
|
|
asset.provider === VideoAssetProvider.BUNNY
|
|
) {
|
|
return `/api/videos/${videoId}/assets/${asset.id}/download`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function bigintToSafeNumber(value: bigint): number | null {
|
|
if (value <= BigInt(0)) return null;
|
|
if (value > BigInt(Number.MAX_SAFE_INTEGER)) return Number.MAX_SAFE_INTEGER;
|
|
return Number(value);
|
|
}
|
|
|
|
export type BuildProjectDownloadManifestOptions = {
|
|
/** Include every version of each video. Defaults to latest version only. */
|
|
includeAllVersions?: boolean;
|
|
/** Include b-rolls and other attached assets. Defaults to videos only. */
|
|
includeAssets?: boolean;
|
|
};
|
|
|
|
export function buildProjectDownloadManifest(
|
|
projectName: string,
|
|
videos: VideoRow[],
|
|
options: BuildProjectDownloadManifestOptions = {}
|
|
): ProjectDownloadManifest {
|
|
const { includeAllVersions = false, includeAssets = false } = options;
|
|
const files: ProjectDownloadManifestFile[] = [];
|
|
const usedNames = new Set<string>();
|
|
|
|
const sortedVideos = [...videos].sort(
|
|
(a, b) => a.position - b.position || a.id.localeCompare(b.id)
|
|
);
|
|
|
|
sortedVideos.forEach((video, index) => {
|
|
const videoIndex = index + 1;
|
|
const videoTitle = sanitizeFileName(video.title) || `video-${videoIndex}`;
|
|
|
|
const versionsToInclude = includeAllVersions
|
|
? video.versions
|
|
: selectLatestVersion(video.versions);
|
|
|
|
for (const version of versionsToInclude) {
|
|
const url = versionDownloadUrl(version);
|
|
if (!url) continue;
|
|
|
|
files.push({
|
|
fileName: makeUniqueName(buildVersionFileName(videoIndex, videoTitle, version), usedNames),
|
|
url,
|
|
sizeBytes: bigintToSafeNumber(version.sizeBytes),
|
|
});
|
|
}
|
|
|
|
if (includeAssets) {
|
|
for (const asset of video.assets) {
|
|
const url = assetDownloadUrl(video.id, asset);
|
|
if (!url) continue;
|
|
|
|
files.push({
|
|
fileName: makeUniqueName(buildAssetFileName(videoIndex, videoTitle, asset), usedNames),
|
|
url,
|
|
sizeBytes: bigintToSafeNumber(asset.sizeBytes),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
const knownTotal = files.reduce((sum, file) => sum + (file.sizeBytes ?? 0), 0);
|
|
|
|
return {
|
|
projectName,
|
|
files,
|
|
totalFiles: files.length,
|
|
totalBytes: knownTotal > 0 ? String(knownTotal) : null,
|
|
};
|
|
}
|
|
|
|
export function validateProjectDownloadManifest(manifest: ProjectDownloadManifest): string | null {
|
|
if (manifest.files.length === 0) {
|
|
return 'No downloadable files found for this selection';
|
|
}
|
|
|
|
const { maxFiles, maxBytes } = getProjectDownloadLimits();
|
|
if (manifest.files.length > maxFiles) {
|
|
return `This download includes ${manifest.files.length} files, which exceeds the limit of ${maxFiles}. Try selecting fewer videos.`;
|
|
}
|
|
|
|
if (manifest.totalBytes) {
|
|
const knownTotal = BigInt(manifest.totalBytes);
|
|
if (knownTotal > maxBytes) {
|
|
const maxGiB = Number(maxBytes / BigInt(1024 * 1024 * 1024));
|
|
return `This download is too large (over ${maxGiB} GiB). Try selecting fewer videos.`;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function parseRequestedVideoIds(raw: string | null): string[] | null {
|
|
if (raw === null) return null;
|
|
const ids = raw
|
|
.split(',')
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
if (ids.length === 0) return [];
|
|
return [...new Set(ids)];
|
|
}
|