import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; import { toast } from "sonner"; import { getMediaTypeFromFile } from "@/media/media-utils"; import { formatStorageBytes } from "@/services/storage/quota"; import { storageService } from "@/services/storage/service"; import type { MediaAsset } from "@/media/types"; import { getVideoInfo } from "./mediabunny"; export interface ProcessedMediaAsset extends Omit {} const THUMBNAIL_MAX_WIDTH = 1280; const THUMBNAIL_MAX_HEIGHT = 720; const getStorageLimitDescription = ({ fileSize, availableBytes, }: { fileSize: number; availableBytes: number | null; }): string => { const fileSizeLabel = formatStorageBytes({ bytes: fileSize }); if (availableBytes === null) { return `File size is ${fileSizeLabel}.`; } return `File size is ${fileSizeLabel}, but only ${formatStorageBytes({ bytes: availableBytes, })} is safely available in browser storage.`; }; const getThumbnailSize = ({ width, height, }: { width: number; height: number; }): { width: number; height: number } => { const aspectRatio = width / height; let targetWidth = width; let targetHeight = height; if (targetWidth > THUMBNAIL_MAX_WIDTH) { targetWidth = THUMBNAIL_MAX_WIDTH; targetHeight = Math.round(targetWidth / aspectRatio); } if (targetHeight > THUMBNAIL_MAX_HEIGHT) { targetHeight = THUMBNAIL_MAX_HEIGHT; targetWidth = Math.round(targetHeight * aspectRatio); } return { width: targetWidth, height: targetHeight }; }; const renderToThumbnailDataUrl = ({ width, height, draw, }: { width: number; height: number; draw: ({ context, width, height, }: { context: CanvasRenderingContext2D; width: number; height: number; }) => void; }): string => { const size = getThumbnailSize({ width, height }); const canvas = document.createElement("canvas"); canvas.width = size.width; canvas.height = size.height; const context = canvas.getContext("2d"); if (!context) { throw new Error("Could not get canvas context"); } draw({ context, width: size.width, height: size.height }); return canvas.toDataURL("image/jpeg", 0.8); }; async function generateThumbnail({ videoFile, timeInSeconds, }: { videoFile: File; timeInSeconds: number; }): Promise { const input = new Input({ source: new BlobSource(videoFile), formats: ALL_FORMATS, }); const videoTrack = await input.getPrimaryVideoTrack(); if (!videoTrack) { throw new Error("No video track found in the file"); } const canDecode = await videoTrack.canDecode(); if (!canDecode) { throw new Error("Video codec not supported for decoding"); } const sink = new VideoSampleSink(videoTrack); const frame = await sink.getSample(timeInSeconds); if (!frame) { throw new Error("Could not get frame at specified time"); } try { return renderToThumbnailDataUrl({ width: videoTrack.displayWidth, height: videoTrack.displayHeight, draw: ({ context, width, height }) => { frame.draw(context, 0, 0, width, height); }, }); } finally { frame.close(); } } async function generateImageThumbnail({ imageFile, }: { imageFile: File; }): Promise<{ thumbnailUrl: string; width: number; height: number }> { return new Promise((resolve, reject) => { const image = new window.Image(); const objectUrl = URL.createObjectURL(imageFile); image.addEventListener("load", () => { try { const thumbnailUrl = renderToThumbnailDataUrl({ width: image.naturalWidth, height: image.naturalHeight, draw: ({ context, width, height }) => { context.drawImage(image, 0, 0, width, height); }, }); resolve({ thumbnailUrl, width: image.naturalWidth, height: image.naturalHeight, }); } catch (error) { reject( error instanceof Error ? error : new Error("Could not render image"), ); } finally { URL.revokeObjectURL(objectUrl); image.remove(); } }); image.addEventListener("error", () => { URL.revokeObjectURL(objectUrl); image.remove(); reject(new Error("Could not load image")); }); image.src = objectUrl; }); } export async function processMediaAssets({ files, onProgress, }: { files: FileList | File[]; onProgress?: ({ progress }: { progress: number }) => void; }): Promise { const fileArray = Array.from(files); const processedAssets: ProcessedMediaAsset[] = []; const total = fileArray.length; let completed = 0; for (const file of fileArray) { const fileType = getMediaTypeFromFile({ file }); if (!fileType) { toast.error(`Unsupported file type: ${file.name}`); continue; } const storageCheck = await storageService.canStoreFile({ size: file.size, }); if (!storageCheck.canStore) { toast.error(`Not enough browser storage for ${file.name}`, { description: getStorageLimitDescription({ fileSize: file.size, availableBytes: storageCheck.availableBytes, }), }); continue; } const url = URL.createObjectURL(file); let thumbnailUrl: string | undefined; let duration: number | undefined; let width: number | undefined; let height: number | undefined; let fps: number | undefined; let hasAudio: boolean | undefined; try { if (fileType === "image") { const result = await generateImageThumbnail({ imageFile: file }); thumbnailUrl = result.thumbnailUrl; width = result.width; height = result.height; } else if (fileType === "video") { try { const videoInfo = await getVideoInfo({ videoFile: file }); duration = videoInfo.duration; width = videoInfo.width; height = videoInfo.height; fps = Number.isFinite(videoInfo.fps) ? Math.round(videoInfo.fps) : undefined; hasAudio = videoInfo.hasAudio; thumbnailUrl = await generateThumbnail({ videoFile: file, timeInSeconds: 1, }); } catch (error) { console.warn("Video processing failed", error); } } else if (fileType === "audio") { // For audio, we don't set width/height/fps (they'll be undefined) duration = await getMediaDuration({ file }); } processedAssets.push({ name: file.name, type: fileType, file, url, thumbnailUrl, duration, width, height, fps, hasAudio, }); await new Promise((resolve) => setTimeout(resolve, 0)); completed += 1; if (onProgress) { const percent = Math.round((completed / total) * 100); onProgress({ progress: percent }); } } catch (error) { console.error("Error processing file:", file.name, error); toast.error(`Failed to process ${file.name}`); URL.revokeObjectURL(url); // Clean up on error } } return processedAssets; } const getMediaDuration = ({ file }: { file: File }): Promise => { return new Promise((resolve, reject) => { const element = document.createElement( file.type.startsWith("video/") ? "video" : "audio", ) as HTMLVideoElement; const objectUrl = URL.createObjectURL(file); element.addEventListener("loadedmetadata", () => { resolve(element.duration); URL.revokeObjectURL(objectUrl); element.remove(); }); element.addEventListener("error", () => { reject(new Error("Could not load media")); URL.revokeObjectURL(objectUrl); element.remove(); }); element.src = objectUrl; element.load(); }); };