220 lines
5.7 KiB
TypeScript
220 lines
5.7 KiB
TypeScript
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 { readVideoFile } from "./mediabunny";
|
|
import type { VideoFileData } from "./mediabunny";
|
|
import { renderThumbnailDataUrl } from "./thumbnail";
|
|
|
|
export interface ProcessedMediaAsset extends Omit<MediaAsset, "id"> {}
|
|
|
|
const getUnsupportedVideoDescription = ({
|
|
codec,
|
|
}: {
|
|
codec: VideoFileData["codec"];
|
|
}): string => {
|
|
const codecLabel = codec ? codec.toUpperCase() : "this video codec";
|
|
|
|
return codec === "hevc"
|
|
? `${codecLabel} cannot be decoded in this browser, so this clip may not preview correctly. Convert it to H.264 MP4 or try importing it in Safari.`
|
|
: `${codecLabel} cannot be decoded in this browser, so this clip may not preview correctly. Convert it to H.264 MP4 and reimport it.`;
|
|
};
|
|
|
|
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.`;
|
|
};
|
|
|
|
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 = renderThumbnailDataUrl({
|
|
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 videoData = await readVideoFile({ file });
|
|
duration = videoData.duration;
|
|
width = videoData.width;
|
|
height = videoData.height;
|
|
fps = Number.isFinite(videoData.fps)
|
|
? Math.round(videoData.fps)
|
|
: undefined;
|
|
hasAudio = videoData.hasAudio;
|
|
thumbnailUrl = videoData.thumbnailUrl ?? undefined;
|
|
|
|
if (!videoData.canDecode) {
|
|
toast.error(`Can't preview ${file.name}`, {
|
|
description: getUnsupportedVideoDescription({
|
|
codec: videoData.codec,
|
|
}),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: "Could not process video";
|
|
|
|
toast.error(`Couldn't process ${file.name}`, {
|
|
description: message,
|
|
});
|
|
}
|
|
} else if (fileType === "audio") {
|
|
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);
|
|
}
|
|
}
|
|
|
|
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();
|
|
});
|
|
};
|