diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/media/processing.ts | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/media/processing.ts')
| -rw-r--r-- | apps/web/src/media/processing.ts | 296 |
1 files changed, 296 insertions, 0 deletions
diff --git a/apps/web/src/media/processing.ts b/apps/web/src/media/processing.ts new file mode 100644 index 00000000..d8d483f8 --- /dev/null +++ b/apps/web/src/media/processing.ts @@ -0,0 +1,296 @@ +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<MediaAsset, "id"> {} + +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<string> { + 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<ProcessedMediaAsset[]> { + 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<number> => { + 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(); + }); +}; |
