283 lines
6.7 KiB
TypeScript
283 lines
6.7 KiB
TypeScript
import { toast } from "sonner";
|
|
import type { MediaAsset } from "@/types/assets";
|
|
import { getMediaTypeFromFile } from "@/lib/media/media-utils";
|
|
import { getVideoInfo } from "./mediabunny";
|
|
import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
|
|
|
|
export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> {}
|
|
|
|
const THUMBNAIL_MAX_WIDTH = 1280;
|
|
const THUMBNAIL_MAX_HEIGHT = 720;
|
|
|
|
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);
|
|
};
|
|
|
|
export 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();
|
|
}
|
|
}
|
|
|
|
export async function generateImageThumbnail({
|
|
imageFile,
|
|
}: {
|
|
imageFile: File;
|
|
}): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const image = new window.Image();
|
|
const objectUrl = URL.createObjectURL(imageFile);
|
|
|
|
image.addEventListener("load", () => {
|
|
try {
|
|
const dataUrl = renderToThumbnailDataUrl({
|
|
width: image.naturalWidth,
|
|
height: image.naturalHeight,
|
|
draw: ({ context, width, height }) => {
|
|
context.drawImage(image, 0, 0, width, height);
|
|
},
|
|
});
|
|
resolve(dataUrl);
|
|
} 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 url = URL.createObjectURL(file);
|
|
let thumbnailUrl: string | undefined;
|
|
let duration: number | undefined;
|
|
let width: number | undefined;
|
|
let height: number | undefined;
|
|
let fps: number | undefined;
|
|
|
|
try {
|
|
if (fileType === "image") {
|
|
const dimensions = await getImageDimensions({ file });
|
|
width = dimensions.width;
|
|
height = dimensions.height;
|
|
thumbnailUrl = await generateImageThumbnail({ imageFile: file });
|
|
} 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 * 1000) / 1000
|
|
: undefined;
|
|
|
|
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,
|
|
});
|
|
|
|
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 getImageDimensions = ({
|
|
file,
|
|
}: {
|
|
file: File;
|
|
}): Promise<{ width: number; height: number }> => {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new window.Image();
|
|
const objectUrl = URL.createObjectURL(file);
|
|
|
|
img.addEventListener("load", () => {
|
|
const width = img.naturalWidth;
|
|
const height = img.naturalHeight;
|
|
resolve({ width, height });
|
|
URL.revokeObjectURL(objectUrl);
|
|
img.remove();
|
|
});
|
|
|
|
img.addEventListener("error", () => {
|
|
reject(new Error("Could not load image"));
|
|
URL.revokeObjectURL(objectUrl);
|
|
img.remove();
|
|
});
|
|
|
|
img.src = objectUrl;
|
|
});
|
|
};
|
|
|
|
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();
|
|
});
|
|
};
|