summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/ffmpeg-utils.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-08-23 11:00:59 +0200
committerMaze Winther <mazewinther@gmail.com>2025-08-23 11:00:59 +0200
commita4f2273e25995be234f20fb1c40d410620706e20 (patch)
tree56ea07e98c8d47c6384a7a80506c168aa4f7418d /apps/web/src/lib/ffmpeg-utils.ts
parent7756ed0d801f382f040f04ba58448040c5b01084 (diff)
downloadopencut-a4f2273e25995be234f20fb1c40d410620706e20.tar.gz
feat: mediabunny + preview canvas refactor
Diffstat (limited to 'apps/web/src/lib/ffmpeg-utils.ts')
-rw-r--r--apps/web/src/lib/ffmpeg-utils.ts468
1 files changed, 0 insertions, 468 deletions
diff --git a/apps/web/src/lib/ffmpeg-utils.ts b/apps/web/src/lib/ffmpeg-utils.ts
deleted file mode 100644
index 0643ecb9..00000000
--- a/apps/web/src/lib/ffmpeg-utils.ts
+++ /dev/null
@@ -1,468 +0,0 @@
-import { FFmpeg } from "@ffmpeg/ffmpeg";
-import { toBlobURL } from "@ffmpeg/util";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-
-let ffmpeg: FFmpeg | null = null;
-
-export const initFFmpeg = async (): Promise<FFmpeg> => {
- if (ffmpeg) return ffmpeg;
-
- ffmpeg = new FFmpeg();
- await ffmpeg.load(); // Use default config
-
- return ffmpeg;
-};
-
-export const generateThumbnail = async (
- videoFile: File,
- timeInSeconds = 1
-): Promise<string> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
- const outputName = "thumbnail.jpg";
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- // Generate thumbnail at specific time
- await ffmpeg.exec([
- "-i",
- inputName,
- "-ss",
- timeInSeconds.toString(),
- "-vframes",
- "1",
- "-vf",
- "scale=320:240",
- "-q:v",
- "2",
- outputName,
- ]);
-
- // Read output file
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "image/jpeg" });
-
- // Cleanup
- await ffmpeg.deleteFile(inputName);
- await ffmpeg.deleteFile(outputName);
-
- return URL.createObjectURL(blob);
-};
-
-export const trimVideo = async (
- videoFile: File,
- startTime: number,
- endTime: number,
- onProgress?: (progress: number) => void
-): Promise<Blob> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
- const outputName = "output.mp4";
-
- // Set up progress callback
- if (onProgress) {
- ffmpeg.on("progress", ({ progress }) => {
- onProgress(progress * 100);
- });
- }
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- const duration = endTime - startTime;
-
- // Trim video
- await ffmpeg.exec([
- "-i",
- inputName,
- "-ss",
- startTime.toString(),
- "-t",
- duration.toString(),
- "-c",
- "copy", // Use stream copy for faster processing
- outputName,
- ]);
-
- // Read output file
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "video/mp4" });
-
- // Cleanup
- await ffmpeg.deleteFile(inputName);
- await ffmpeg.deleteFile(outputName);
-
- return blob;
-};
-
-export const getVideoInfo = async (
- videoFile: File
-): Promise<{
- duration: number;
- width: number;
- height: number;
- fps: number;
-}> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- // Capture FFmpeg stderr output with a one-time listener pattern
- let ffmpegOutput = "";
- let listening = true;
- const listener = (data: string) => {
- if (listening) ffmpegOutput += data;
- };
- ffmpeg.on("log", ({ message }) => listener(message));
-
- // Run ffmpeg to get info (stderr will contain the info)
- try {
- await ffmpeg.exec(["-i", inputName, "-f", "null", "-"]);
- } catch (error) {
- listening = false;
- await ffmpeg.deleteFile(inputName);
- console.error("FFmpeg execution failed:", error);
- throw new Error(
- "Failed to extract video info. The file may be corrupted or in an unsupported format."
- );
- }
-
- // Disable listener after exec completes
- listening = false;
-
- // Cleanup
- await ffmpeg.deleteFile(inputName);
-
- // Parse output for duration, resolution, and fps
- // Example: Duration: 00:00:10.00, start: 0.000000, bitrate: 1234 kb/s
- // Example: Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 30 fps, 30 tbr, 90k tbn, 60 tbc
-
- const durationMatch = ffmpegOutput.match(/Duration: (\d+):(\d+):([\d.]+)/);
- let duration = 0;
- if (durationMatch) {
- const [, h, m, s] = durationMatch;
- duration = parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
- }
-
- const videoStreamMatch = ffmpegOutput.match(
- /Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/
- );
- let width = 0,
- height = 0,
- fps = 0;
- if (videoStreamMatch) {
- width = parseInt(videoStreamMatch[1]);
- height = parseInt(videoStreamMatch[2]);
- fps = parseFloat(videoStreamMatch[3]);
- }
-
- return {
- duration,
- width,
- height,
- fps,
- };
-};
-
-export const convertToWebM = async (
- videoFile: File,
- onProgress?: (progress: number) => void
-): Promise<Blob> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
- const outputName = "output.webm";
-
- // Set up progress callback
- if (onProgress) {
- ffmpeg.on("progress", ({ progress }) => {
- onProgress(progress * 100);
- });
- }
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- // Convert to WebM
- await ffmpeg.exec([
- "-i",
- inputName,
- "-c:v",
- "libvpx-vp9",
- "-crf",
- "30",
- "-b:v",
- "0",
- "-c:a",
- "libopus",
- outputName,
- ]);
-
- // Read output file
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "video/webm" });
-
- // Cleanup
- await ffmpeg.deleteFile(inputName);
- await ffmpeg.deleteFile(outputName);
-
- return blob;
-};
-
-export const extractAudio = async (
- videoFile: File,
- format: "mp3" | "wav" = "mp3"
-): Promise<Blob> => {
- const ffmpeg = await initFFmpeg();
-
- const inputName = "input.mp4";
- const outputName = `output.${format}`;
-
- // Write input file
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await videoFile.arrayBuffer())
- );
-
- // Extract audio
- await ffmpeg.exec([
- "-i",
- inputName,
- "-vn", // Disable video
- "-acodec",
- format === "mp3" ? "libmp3lame" : "pcm_s16le",
- outputName,
- ]);
-
- // Read output file
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: `audio/${format}` });
-
- // Cleanup
- await ffmpeg.deleteFile(inputName);
- await ffmpeg.deleteFile(outputName);
-
- return blob;
-};
-
-export const extractTimelineAudio = async (
- onProgress?: (progress: number) => void
-): Promise<Blob> => {
- // Create fresh FFmpeg instance for this operation
- const ffmpeg = new FFmpeg();
-
- try {
- await ffmpeg.load();
- } catch (error) {
- console.error("Failed to load fresh FFmpeg instance:", error);
- throw new Error("Unable to initialize audio processing. Please try again.");
- }
-
- const timeline = useTimelineStore.getState();
- const mediaStore = useMediaStore.getState();
-
- const tracks = timeline.tracks;
- const totalDuration = timeline.getTotalDuration();
-
- if (totalDuration === 0) {
- const emptyAudioData = new ArrayBuffer(44);
- return new Blob([emptyAudioData], { type: "audio/wav" });
- }
-
- if (onProgress) {
- ffmpeg.on("progress", ({ progress }) => {
- onProgress(progress * 100);
- });
- }
-
- const audioElements: Array<{
- file: File;
- startTime: number;
- duration: number;
- trimStart: number;
- trimEnd: number;
- trackMuted: boolean;
- }> = [];
-
- for (const track of tracks) {
- if (track.muted) continue;
-
- for (const element of track.elements) {
- if (element.type === "media") {
- const mediaItem = mediaStore.mediaItems.find(
- (m) => m.id === element.mediaId
- );
- if (!mediaItem) continue;
-
- if (mediaItem.type === "video" || mediaItem.type === "audio") {
- audioElements.push({
- file: mediaItem.file,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
- trackMuted: track.muted || false,
- });
- }
- }
- }
- }
-
- if (audioElements.length === 0) {
- // Return silent audio if no audio elements
- const silentDuration = Math.max(1, totalDuration); // At least 1 second
- try {
- const silentAudio = await generateSilentAudio(silentDuration);
- return silentAudio;
- } catch (error) {
- console.error("Failed to generate silent audio:", error);
- throw new Error("Unable to generate audio for empty timeline.");
- }
- }
-
- // Create a complex filter to mix all audio sources
- const inputFiles: string[] = [];
- const filterInputs: string[] = [];
-
- try {
- for (let i = 0; i < audioElements.length; i++) {
- const element = audioElements[i];
- const inputName = `input_${i}.${element.file.name.split(".").pop()}`;
- inputFiles.push(inputName);
-
- try {
- await ffmpeg.writeFile(
- inputName,
- new Uint8Array(await element.file.arrayBuffer())
- );
- } catch (error) {
- console.error(`Failed to write file ${element.file.name}:`, error);
- throw new Error(
- `Unable to process file: ${element.file.name}. The file may be corrupted or in an unsupported format.`
- );
- }
-
- const actualStart = element.trimStart;
- const actualDuration =
- element.duration - element.trimStart - element.trimEnd;
-
- const filterName = `audio_${i}`;
- filterInputs.push(
- `[${i}:a]atrim=start=${actualStart}:duration=${actualDuration},asetpts=PTS-STARTPTS,adelay=${element.startTime * 1000}|${element.startTime * 1000}[${filterName}]`
- );
- }
-
- const mixFilter =
- audioElements.length === 1
- ? `[audio_0]aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`
- : `${filterInputs.map((_, i) => `[audio_${i}]`).join("")}amix=inputs=${audioElements.length}:duration=longest:dropout_transition=2,aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[out]`;
-
- const complexFilter = [...filterInputs, mixFilter].join(";");
- const outputName = "timeline_audio.wav";
-
- const ffmpegArgs = [
- ...inputFiles.flatMap((name) => ["-i", name]),
- "-filter_complex",
- complexFilter,
- "-map",
- "[out]",
- "-t",
- totalDuration.toString(),
- "-c:a",
- "pcm_s16le",
- "-ar",
- "44100",
- outputName,
- ];
-
- try {
- await ffmpeg.exec(ffmpegArgs);
- } catch (error) {
- console.error("FFmpeg execution failed:", error);
- throw new Error(
- "Audio processing failed. Some audio files may be corrupted or incompatible."
- );
- }
-
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "audio/wav" });
-
- return blob;
- } catch (error) {
- for (const inputFile of inputFiles) {
- try {
- await ffmpeg.deleteFile(inputFile);
- } catch (cleanupError) {
- console.warn(`Failed to cleanup file ${inputFile}:`, cleanupError);
- }
- }
- try {
- await ffmpeg.deleteFile("timeline_audio.wav");
- } catch (cleanupError) {
- console.warn("Failed to cleanup output file:", cleanupError);
- }
-
- throw error;
- } finally {
- for (const inputFile of inputFiles) {
- try {
- await ffmpeg.deleteFile(inputFile);
- } catch (cleanupError) {}
- }
- try {
- await ffmpeg.deleteFile("timeline_audio.wav");
- } catch (cleanupError) {}
- }
-};
-
-const generateSilentAudio = async (durationSeconds: number): Promise<Blob> => {
- const ffmpeg = await initFFmpeg();
- const outputName = "silent.wav";
-
- try {
- await ffmpeg.exec([
- "-f",
- "lavfi",
- "-i",
- `anullsrc=channel_layout=stereo:sample_rate=44100`,
- "-t",
- durationSeconds.toString(),
- "-c:a",
- "pcm_s16le",
- outputName,
- ]);
-
- const data = await ffmpeg.readFile(outputName);
- const blob = new Blob([data], { type: "audio/wav" });
-
- return blob;
- } catch (error) {
- console.error("Failed to generate silent audio:", error);
- throw error;
- } finally {
- try {
- await ffmpeg.deleteFile(outputName);
- } catch (cleanupError) {
- // Silent cleanup
- }
- }
-};