import { FFmpeg } from '@ffmpeg/ffmpeg'; import { fetchFile, toBlobURL } from '@ffmpeg/util'; import { useCallback, useMemo, useState } from 'react'; const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { const [showConsoleLogs] = useState(false); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(0); const [exportStatus, setExportStatus] = useState(''); const generateFFmpegCommand = useCallback( (is_string = true, useLocalFiles = false) => { showConsoleLogs && console.log('🎬 STARTING FFmpeg generation'); const videos = timelineElements.filter((el) => el.type === 'video'); const images = timelineElements.filter((el) => el.type === 'image'); const texts = timelineElements.filter((el) => el.type === 'text'); showConsoleLogs && console.log('Videos found:', videos.length); showConsoleLogs && console.log('Images found:', images.length); if (videos.length === 0 && images.length === 0) { if (is_string) { return 'ffmpeg -f lavfi -i color=black:size=450x800:duration=1 -c:v libx264 -t 1 output.mp4'; } else { return ['-f', 'lavfi', '-i', 'color=black:size=450x800:duration=1', '-c:v', 'libx264', '-t', '1', 'output.mp4']; } } let inputArgs = []; let inputIndex = 0; // Add video inputs videos.forEach((v, i) => { inputArgs.push('-i'); inputArgs.push(useLocalFiles ? `input_video_${i}.webm` : v.source_webm); inputIndex++; }); // Add image inputs with loop and duration images.forEach((img, i) => { inputArgs.push('-loop', '1', '-t', img.duration.toString(), '-i'); inputArgs.push(useLocalFiles ? `input_image_${i}.jpg` : img.source); inputIndex++; }); let filters = []; filters.push(`color=black:size=${dimensions.width}x${dimensions.height}:duration=${totalDuration}[base]`); let videoLayer = 'base'; let currentInputIndex = 0; // Process video elements videos.forEach((v, i) => { filters.push(`[${currentInputIndex}:v]trim=start=${v.inPoint}:duration=${v.duration},setpts=PTS-STARTPTS[v${i}_trim]`); filters.push(`[v${i}_trim]scale=${Math.round(v.width)}:${Math.round(v.height)}[v${i}_scale]`); filters.push( `[${videoLayer}][v${i}_scale]overlay=${Math.round(v.x)}:${Math.round(v.y)}:enable='between(t,${v.startTime},${ v.startTime + v.duration })'[v${i}_out]`, ); videoLayer = `v${i}_out`; currentInputIndex++; }); // Process image elements images.forEach((img, i) => { const imgInputIndex = currentInputIndex; filters.push(`[${imgInputIndex}:v]scale=${Math.round(img.width)}:${Math.round(img.height)}[img${i}_scale]`); filters.push( `[${videoLayer}][img${i}_scale]overlay=${Math.round(img.x)}:${Math.round(img.y)}:enable='between(t,${img.startTime},${ img.startTime + img.duration })'[img${i}_out]`, ); videoLayer = `img${i}_out`; currentInputIndex++; }); showConsoleLogs && console.log('🎵 PROCESSING AUDIO FOR', videos.length, 'VIDEOS'); let audioOutputs = []; videos.forEach((v, i) => { const delay = Math.round(v.startTime * 1000); showConsoleLogs && console.log(`🎵 Audio ${i}: delay=${delay}ms, inPoint=${v.inPoint}, duration=${v.duration}`); filters.push(`[${i}:a]atrim=start=${v.inPoint}:duration=${v.duration},asetpts=PTS-STARTPTS,adelay=${delay}|${delay}[a${i}]`); audioOutputs.push(`[a${i}]`); }); let audioArgs = []; if (audioOutputs.length === 1) { filters.push(`[a0]apad=pad_dur=${totalDuration}[audio_final]`); audioArgs = ['-map', '[audio_final]', '-c:a', 'aac']; } else if (audioOutputs.length > 1) { filters.push(`${audioOutputs.join('')}amix=inputs=${audioOutputs.length}:duration=longest[audio_final]`); audioArgs = ['-map', '[audio_final]', '-c:a', 'aac']; } showConsoleLogs && console.log('🎵 Audio args:', audioArgs); texts.forEach((t, i) => { const escapedText = t.text.replace(/'/g, is_string ? "\\'" : "'").replace(/:/g, '\\:'); filters.push( `[${videoLayer}]drawtext=fontfile=/arial.ttf:text='${escapedText}':x=${Math.round( t.x, )}:y=${Math.round(t.y)}:fontsize=${t.fontSize}:fontcolor=${t.fill}:borderw=${t.strokeWidth}:bordercolor=${ t.stroke }:enable='between(t,${t.startTime},${t.startTime + t.duration})'[t${i}]`, ); videoLayer = `t${i}`; }); const filterComplex = filters.join('; '); showConsoleLogs && console.log('🎵 Filter includes atrim:', filterComplex.includes('atrim')); const finalArgs = [ ...inputArgs, '-filter_complex', filterComplex, '-map', `[${videoLayer}]`, ...audioArgs, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-r', '30', '-t', totalDuration.toString(), 'output.mp4', ]; if (is_string) { let inputStrings = []; let inputIdx = 0; videos.forEach((v, i) => { inputStrings.push(`-i "${useLocalFiles ? `input_video_${i}.webm` : v.source_webm}"`); }); images.forEach((img, i) => { inputStrings.push(`-loop 1 -t ${img.duration} -i "${useLocalFiles ? `input_image_${i}.jpg` : img.source}"`); }); const inputs = inputStrings.join(' '); const audioMap = audioArgs.length > 0 ? ` ${audioArgs.join(' ')}` : ''; const command = `ffmpeg ${inputs} -filter_complex "${filterComplex}" -map "[${videoLayer}]"${audioMap} -c:v libx264 -pix_fmt yuv420p -r 30 -t ${totalDuration} output.mp4`; showConsoleLogs && console.log('🎵 FINAL COMMAND HAS AUDIO:', command.includes('atrim') && command.includes('audio_final')); return command; } else { showConsoleLogs && console.log('🎵 FINAL ARGS HAVE AUDIO:', finalArgs.includes('atrim') && finalArgs.includes('audio_final')); return finalArgs; } }, [timelineElements, dimensions, totalDuration], ); const ffmpegCommand = useMemo(() => { return generateFFmpegCommand(true, false); }, [generateFFmpegCommand]); const copyFFmpegCommand = useCallback(() => { showConsoleLogs && console.log('🎬 FFMPEG COMMAND GENERATED:'); showConsoleLogs && console.log('Command:', ffmpegCommand); navigator.clipboard.writeText(ffmpegCommand); }, [ffmpegCommand]); const exportVideo = async () => { setIsExporting(true); setExportProgress(0); setExportStatus('Starting export...'); try { setExportStatus('Loading FFmpeg...'); const ffmpeg = new FFmpeg(); ffmpeg.on('progress', ({ progress }) => { setExportProgress(Math.round(progress * 100)); }); ffmpeg.on('log', ({ message }) => { showConsoleLogs && console.log(message); }); const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm'; const coreURL = `${baseURL}/ffmpeg-core.js`; const wasmURL = `${baseURL}/ffmpeg-core.wasm`; showConsoleLogs && console.log('Converting JS coreURL...'); const coreBlobURL = await toBlobURL(coreURL, 'text/javascript'); showConsoleLogs && console.log('JS coreURL ready:', coreBlobURL); showConsoleLogs && console.log('Converting WASM URL...'); const wasmBlobURL = await toBlobURL(wasmURL, 'application/wasm'); showConsoleLogs && console.log('WASM URL ready:', wasmBlobURL); showConsoleLogs && console.log('Calling ffmpeg.load...'); await ffmpeg.load({ coreURL: coreBlobURL, wasmURL: wasmBlobURL, }); showConsoleLogs && console.log('FFmpeg loaded!'); setExportProgress(20); setExportStatus('Loading font...'); await ffmpeg.writeFile('arial.ttf', await fetchFile('https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf')); showConsoleLogs && console.log('Font loaded!'); setExportProgress(30); setExportStatus('Downloading media...'); const videos = timelineElements.filter((el) => el.type === 'video'); const images = timelineElements.filter((el) => el.type === 'image'); const totalMedia = videos.length + images.length; let mediaProgress = 0; // Download videos for (let i = 0; i < videos.length; i++) { await ffmpeg.writeFile(`input_video_${i}.webm`, await fetchFile(videos[i].source_webm)); mediaProgress++; setExportProgress(30 + Math.round((mediaProgress / totalMedia) * 30)); } // Download images for (let i = 0; i < images.length; i++) { await ffmpeg.writeFile(`input_image_${i}.jpg`, await fetchFile(images[i].source)); mediaProgress++; setExportProgress(30 + Math.round((mediaProgress / totalMedia) * 30)); } setExportStatus('Processing video...'); let args = generateFFmpegCommand(false, true); setExportProgress(70); await ffmpeg.exec(args); setExportStatus('Downloading...'); setExportProgress(90); const fileData = await ffmpeg.readFile('output.mp4'); const data = new Uint8Array(fileData); const blob = new Blob([data.buffer], { type: 'video/mp4' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = 'exported_video.mp4'; link.click(); URL.revokeObjectURL(url); setExportProgress(100); setExportStatus('Complete!'); ffmpeg.terminate(); } catch (error) { console.error('Export error:', error); setExportStatus(`Failed: ${error.message}`); } finally { setTimeout(() => { setIsExporting(false); setExportStatus(''); setExportProgress(0); }, 3000); } }; return { // State isExporting, exportProgress, exportStatus, ffmpegCommand, // Functions copyFFmpegCommand, exportVideo, generateFFmpegCommand, }; }; export default useVideoExport;