summaryrefslogtreecommitdiff
path: root/apps/web/src/media/processing.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/media/processing.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-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.ts296
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();
+ });
+};