diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-08-23 11:00:59 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-08-23 11:00:59 +0200 |
| commit | a4f2273e25995be234f20fb1c40d410620706e20 (patch) | |
| tree | 56ea07e98c8d47c6384a7a80506c168aa4f7418d /apps/web/src/lib/ffmpeg-utils.ts | |
| parent | 7756ed0d801f382f040f04ba58448040c5b01084 (diff) | |
| download | opencut-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.ts | 468 |
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 - } - } -}; |
