import { FFmpeg } from '@ffmpeg/ffmpeg'; import { fetchFile, toBlobURL } from '@ffmpeg/util'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import VideoPreview from './video-preview'; const VideoEditor = ({ width, height }) => { const [dimensions] = useState({ width: width, height: height, }); const [timelineElements, setTimelineElements] = useState([ { id: '1', type: 'video', source_webm: 'https://cdn.memeaigen.com/g1/webm/they-not-like-us-oiia-cat-version.webm', source_mov: 'https://cdn.memeaigen.com/g1/mov/they-not-like-us-oiia-cat-version.mov', poster: 'https://cdn.memeaigen.com/g1/webm/they-not-like-us-oiia-cat-version.webp', name: 'They not like us cat', startTime: 0, layer: 0, inPoint: 0, duration: 5, x: 50, y: 50, width: 300, // Will be updated when video loads height: 200, // Will be updated when video loads }, { id: '2', type: 'video', source: 'https://cdn.memeaigen.com/g1/webm/sad-cat.webm', name: 'Sad cat meme', startTime: 6, layer: 0, inPoint: 2, duration: 4, x: 100, y: 100, width: 250, // Will be updated when video loads height: 150, // Will be updated when video loads }, { id: '3', type: 'video', source: 'https://cdn.memeaigen.com/g1/webm/este-cat-dance.webm', name: 'Este cat dance', startTime: 2, layer: 1, inPoint: 1, duration: 6, x: 200, y: 200, width: 280, // Will be updated when video loads height: 180, // Will be updated when video loads }, { id: '4', type: 'text', text: 'Welcome to the Timeline!', startTime: 1, layer: 2, duration: 3, x: 50, y: 600, fontSize: 24, fill: 'white', stroke: 'black', strokeWidth: 1, }, { id: '5', type: 'text', text: 'Multiple videos playing!', startTime: 3, layer: 3, duration: 4, x: 50, y: 650, fontSize: 20, fill: 'yellow', stroke: 'red', strokeWidth: 2, }, ]); const lastUpdateRef = useRef(0); // FFmpeg WASM states const ffmpegRef = useRef(new FFmpeg()); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(0); const [exportStatus, setExportStatus] = useState(''); const [currentTime, setCurrentTime] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [videoElements, setVideoElements] = useState({}); const [loadedVideos, setLoadedVideos] = useState(new Set()); const [status, setStatus] = useState('Loading videos...'); // Track which videos should be playing - this is the key optimization const [videoStates, setVideoStates] = useState({}); const animationRef = useRef(null); const layerRef = useRef(null); const startTimeRef = useRef(0); const pausedTimeRef = useRef(0); // Calculate total timeline duration const totalDuration = Math.max(...timelineElements.map((el) => el.startTime + el.duration)); // Generate FFmpeg command - COMPLETE VERSION const generateFFmpegCommand = useCallback( (is_string = true, useLocalFiles = false) => { console.log('🎬 STARTING FFmpeg generation'); const videos = timelineElements.filter((el) => el.type === 'video'); const texts = timelineElements.filter((el) => el.type === 'text'); console.log('Videos found:', videos.length); if (videos.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']; } } // Build inputs let inputArgs = []; videos.forEach((v, i) => { inputArgs.push('-i'); inputArgs.push(useLocalFiles ? `input${i}.webm` : v.source); }); // Build filter parts array let filters = []; // Base canvas filters.push(`color=black:size=${dimensions.width}x${dimensions.height}:duration=${totalDuration}[base]`); // Process video streams let videoLayer = 'base'; videos.forEach((v, i) => { filters.push(`[${i}: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`; }); // AUDIO PROCESSING - EXPLICIT AND COMPLETE console.log('🎵 PROCESSING AUDIO FOR', videos.length, 'VIDEOS'); let audioOutputs = []; videos.forEach((v, i) => { const delay = Math.round(v.startTime * 1000); 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}]`); }); // Audio mixing 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']; } console.log('🎵 Audio args:', audioArgs); // Add text overlays 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}`; }); // Join all filter parts const filterComplex = filters.join('; '); console.log('🎵 Filter includes atrim:', filterComplex.includes('atrim')); // Build final arguments 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) { // Build final command string const inputs = videos.map((v, i) => `-i "${useLocalFiles ? `input${i}.webm` : v.source}"`).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`; console.log('🎵 FINAL COMMAND HAS AUDIO:', command.includes('atrim') && command.includes('audio_final')); return command; } else { console.log('🎵 FINAL ARGS HAVE AUDIO:', finalArgs.includes('atrim') && finalArgs.includes('audio_final')); return finalArgs; } }, [timelineElements, dimensions, totalDuration], ); // Memoize the FFmpeg command const ffmpegCommand = useMemo(() => { return generateFFmpegCommand(true, false); }, [generateFFmpegCommand]); // Memoize the copy function const copyFFmpegCommand = useCallback(() => { console.log('🎬 FFMPEG COMMAND GENERATED:'); console.log('Command:', ffmpegCommand); navigator.clipboard.writeText(ffmpegCommand); }, [ffmpegCommand]); // Create video elements useEffect(() => { const videoEls = {}; const videoElementsData = timelineElements.filter((el) => el.type === 'video'); videoElementsData.forEach((element) => { const video = document.createElement('video'); video.poster = element.poster; video.crossOrigin = 'anonymous'; video.muted = true; // Start muted, unmute on play video.preload = 'auto'; // Preload entire video content video.playsInline = true; // Better mobile performance video.controls = false; // Remove native controls const sourceWebM = document.createElement('source'); sourceWebM.src = element.source_webm; sourceWebM.type = 'video/webm; codecs=vp09.00.41.08'; const sourceMov = document.createElement('source'); sourceMov.src = element.source_mov; sourceMov.type = 'video/quicktime; codecs=hvc1.1.6.H120.b0'; video.appendChild(sourceMov); video.appendChild(sourceWebM); video.addEventListener('loadedmetadata', () => { // Calculate scaling to fit within canvas while maintaining aspect ratio const maxWidth = dimensions.width; const maxHeight = dimensions.height; const videoWidth = video.videoWidth; const videoHeight = video.videoHeight; let scaledWidth = videoWidth; let scaledHeight = videoHeight; // Only scale down if video is larger than canvas if (videoWidth > maxWidth || videoHeight > maxHeight) { const scaleX = maxWidth / videoWidth; const scaleY = maxHeight / videoHeight; const scale = Math.min(scaleX, scaleY); // Use smaller scale to fit both dimensions scaledWidth = videoWidth * scale; scaledHeight = videoHeight * scale; } // Center the video in the canvas const centeredX = (maxWidth - scaledWidth) / 2; const centeredY = (maxHeight - scaledHeight) / 2; // Update timeline element with scaled and centered video setTimelineElements((prev) => prev.map((el) => { if (el.id === element.id && el.type === 'video') { return { ...el, x: centeredX, y: centeredY, width: scaledWidth, height: scaledHeight, }; } return el; }), ); setLoadedVideos((prev) => { const newSet = new Set(prev); newSet.add(element.id); return newSet; }); }); video.addEventListener('error', (e) => { console.error(`Error loading video ${element.id}:`, e); }); videoEls[element.id] = video; }); setVideoElements(videoEls); return () => { Object.values(videoEls).forEach((video) => { video.src = ''; video.load(); }); }; }, []); // Only run once on mount // Update status when videos load useEffect(() => { const videoCount = timelineElements.filter((el) => el.type === 'video').length; if (loadedVideos.size === videoCount && videoCount > 0) { setStatus('Ready to play'); } else if (videoCount > 0) { setStatus(`Loading videos... (${loadedVideos.size}/${videoCount})`); } else { setStatus('Ready to play'); } }, [loadedVideos, timelineElements]); const handlePause = useCallback(() => { if (isPlaying) { setIsPlaying(false); pausedTimeRef.current = currentTime; // Pause and mute all videos when pausing timeline Object.values(videoElements).forEach((video) => { if (!video.paused) { video.pause(); } video.muted = true; }); // Reset video states tracking setVideoStates({}); if (animationRef.current) { animationRef.current.stop(); } } }, [isPlaying, currentTime, videoElements]); 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 }) => { 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`; console.log('Converting JS coreURL...'); const coreBlobURL = await toBlobURL(coreURL, 'text/javascript'); console.log('JS coreURL ready:', coreBlobURL); console.log('Converting WASM URL...'); const wasmBlobURL = await toBlobURL(wasmURL, 'application/wasm'); console.log('WASM URL ready:', wasmBlobURL); console.log('Calling ffmpeg.load...'); await ffmpeg.load({ coreURL: coreBlobURL, wasmURL: wasmBlobURL, }); console.log('FFmpeg loaded!'); setExportProgress(20); // Write arial.ttf font into FFmpeg FS (fetch from GitHub) setExportStatus('Loading font...'); await ffmpeg.writeFile('arial.ttf', await fetchFile('https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf')); console.log('Font loaded!'); setExportProgress(30); // Download videos setExportStatus('Downloading videos...'); const videos = timelineElements.filter((el) => el.type === 'video'); for (let i = 0; i < videos.length; i++) { await ffmpeg.writeFile(`input${i}.webm`, await fetchFile(videos[i].source)); setExportProgress(30 + Math.round(((i + 1) / videos.length) * 30)); } // Generate your FFmpeg command, but be sure to include fontfile=/arial.ttf in all drawtext filters setExportStatus('Processing video...'); let args = generateFFmpegCommand(false, true); // Example: if your command uses drawtext filters, add fontfile argument like: // drawtext=fontfile=/arial.ttf:text='Your text':x=50:y=600:fontsize=24:fontcolor=white:borderw=1:bordercolor=black // Make sure your generateFFmpegCommand function inserts this correctly. setExportProgress(70); await ffmpeg.exec(args); // Download result 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); } }; // Get currently active elements based on timeline position const getActiveElements = useCallback( (time) => { return timelineElements.filter((element) => { const elementEndTime = element.startTime + element.duration; return time >= element.startTime && time < elementEndTime; }); }, [timelineElements], ); // Calculate which videos should be playing based on current time const getDesiredVideoStates = useCallback( (time) => { // Accept time as parameter const states = {}; timelineElements.forEach((element) => { if (element.type === 'video') { const elementEndTime = element.startTime + element.duration; states[element.id] = time >= element.startTime && time < elementEndTime; } }); return states; }, [timelineElements], // Removed dependency on currentTime ); // Update video times based on timeline position - optimized to reduce seeking const updateVideoTimes = useCallback( (time) => { timelineElements.forEach((element) => { if (element.type === 'video' && videoElements[element.id]) { const video = videoElements[element.id]; const elementEndTime = element.startTime + element.duration; if (time >= element.startTime && time < elementEndTime) { const relativeTime = time - element.startTime; const videoTime = element.inPoint + relativeTime; // Only seek if time difference is significant if (Math.abs(video.currentTime - videoTime) > 0.5) { video.currentTime = videoTime; } } } }); }, [timelineElements, videoElements], ); // OPTIMIZED: Manage video play/pause states only when needed useEffect(() => { if (!isPlaying) return; const desiredStates = getDesiredVideoStates(currentTime); // Smarter play/pause without excessive updates Object.entries(desiredStates).forEach(([videoId, shouldPlay]) => { const video = videoElements[videoId]; const isCurrentlyPlaying = !video?.paused; if (video) { if (shouldPlay && !isCurrentlyPlaying) { video.muted = false; video.play().catch((e) => console.warn('Video play failed:', e)); } else if (!shouldPlay && isCurrentlyPlaying) { video.pause(); video.muted = true; } } }); setVideoStates(desiredStates); }, [currentTime, isPlaying, videoElements, getDesiredVideoStates]); const animate = useCallback(() => { if (!isPlaying) return; const now = Date.now() / 1000; const newTime = pausedTimeRef.current + (now - startTimeRef.current); if (newTime >= totalDuration) { handlePause(); handleSeek(0); // ⬅️ Reset timeline return; } if (newTime - lastUpdateRef.current >= 0.05) { lastUpdateRef.current = newTime; setCurrentTime(newTime); updateVideoTimes(newTime); if (layerRef.current) { layerRef.current.batchDraw(); } } }, [isPlaying, totalDuration, updateVideoTimes, handlePause]); // Start animation loop - using requestAnimationFrame for better performance useEffect(() => { if (isPlaying) { let animationId; const animateFrame = () => { animate(); animationId = requestAnimationFrame(animateFrame); }; animationId = requestAnimationFrame(animateFrame); animationRef.current = { stop: () => cancelAnimationFrame(animationId) }; return () => { if (animationRef.current) { animationRef.current.stop(); } }; } }, [isPlaying, animate]); const handlePlay = () => { if (!isPlaying) { setIsPlaying(true); startTimeRef.current = Date.now() / 1000; lastUpdateRef.current = 0; // ✅ Reset debounce tracker setStatus(''); } }; const handleSeek = (time) => { const clampedTime = Math.max(0, Math.min(time, totalDuration)); setCurrentTime(clampedTime); pausedTimeRef.current = clampedTime; updateVideoTimes(clampedTime); // Reset video states when seeking to force re-evaluation setVideoStates({}); if (layerRef.current) { layerRef.current.draw(); } }; const handleReset = () => { handlePause(); handleSeek(0); lastUpdateRef.current = 0; // ✅ Reset debounce tracker // Ensure all videos are muted Object.values(videoElements).forEach((video) => { video.muted = true; }); }; const activeElements = getActiveElements(currentTime); return (
); }; export default VideoEditor;