From a0cff2b48a335e0500fefe5dc23a8e41a288440d Mon Sep 17 00:00:00 2001 From: ct Date: Sun, 15 Jun 2025 09:36:14 +0800 Subject: [PATCH] Update --- .../partials/canvas/sample-timeline-data.jsx | 80 ++++++ .../editor/partials/canvas/video-export.jsx | 234 ++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 resources/js/modules/editor/partials/canvas/sample-timeline-data.jsx create mode 100644 resources/js/modules/editor/partials/canvas/video-export.jsx diff --git a/resources/js/modules/editor/partials/canvas/sample-timeline-data.jsx b/resources/js/modules/editor/partials/canvas/sample-timeline-data.jsx new file mode 100644 index 0000000..718fcaa --- /dev/null +++ b/resources/js/modules/editor/partials/canvas/sample-timeline-data.jsx @@ -0,0 +1,80 @@ +const sampleTimelineElements = [ + { + 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/webp/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, + height: 200, + }, + { + id: '2', + type: 'video', + source_webm: 'https://cdn.memeaigen.com/g1/webm/sad-cat.webm', + source_mov: 'https://cdn.memeaigen.com/g1/mov/sad-cat.mov', + poster: 'https://cdn.memeaigen.com/g1/webp/sad-cat.webp', + name: 'Sad cat meme', + startTime: 6, + layer: 0, + inPoint: 2, + duration: 4, + x: 100, + y: 100, + width: 250, + height: 150, + }, + { + id: '3', + type: 'video', + source_webm: 'https://cdn.memeaigen.com/g1/webm/este-cat-dance.webm', + source_mov: 'https://cdn.memeaigen.com/g1/mov/este-cat-dance.mov', + poster: 'https://cdn.memeaigen.com/g1/webp/este-cat-dance.webp', + name: 'Este cat dance', + startTime: 2, + layer: 1, + inPoint: 1, + duration: 6, + x: 200, + y: 200, + width: 280, + height: 180, + }, + { + 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, + }, +]; + +export default sampleTimelineElements; diff --git a/resources/js/modules/editor/partials/canvas/video-export.jsx b/resources/js/modules/editor/partials/canvas/video-export.jsx new file mode 100644 index 0000000..50a5674 --- /dev/null +++ b/resources/js/modules/editor/partials/canvas/video-export.jsx @@ -0,0 +1,234 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile, toBlobURL } from '@ffmpeg/util'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { + const ffmpegRef = useRef(new FFmpeg()); + + const [isExporting, setIsExporting] = useState(false); + const [exportProgress, setExportProgress] = useState(0); + const [exportStatus, setExportStatus] = useState(''); + + 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']; + } + } + + let inputArgs = []; + videos.forEach((v, i) => { + inputArgs.push('-i'); + inputArgs.push(useLocalFiles ? `input${i}.webm` : v.source); + }); + + let filters = []; + filters.push(`color=black:size=${dimensions.width}x${dimensions.height}:duration=${totalDuration}[base]`); + + 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`; + }); + + 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}]`); + }); + + 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); + + 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('; '); + 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) { + 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], + ); + + const ffmpegCommand = useMemo(() => { + return generateFFmpegCommand(true, false); + }, [generateFFmpegCommand]); + + const copyFFmpegCommand = useCallback(() => { + console.log('🎬 FFMPEG COMMAND GENERATED:'); + 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 }) => { + 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); + + 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); + + 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)); + } + + 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;