From 6a77a8b8c2f5d670240cc3dcc89e1dc07da5d012 Mon Sep 17 00:00:00 2001 From: ct Date: Sat, 21 Jun 2025 10:35:26 +0800 Subject: [PATCH] update --- .DS_Store | Bin 0 -> 6148 bytes .../editor/partials/canvas/video-export.jsx | 72 ++++++++++++------ 2 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..544df98fb647122439b876b3e42e8bd6d1da7607 GIT binary patch literal 6148 zcmeHK!AiqG5S?vnn^1%t6nYGJEf}>H#7n642aM=Jr8Z5_V9b^zHHT8jS%1hc@q3)v z-HNsKDr$FN_RY@DB+SdQn*jjUn*@6R762HigcTEq6+-KzOHxsu8X^{Y7+6~;JjsVq5~d;{3PKFIzKoK97d<{nf=uLka=|bSb6~Zm(?iE;+nsJ_ z*0!fd`(z(*)uEJ8HI680; zEdV0@MhZcjdI`$06}k#biMWEoWGbReWvazsG9CT4igOi~5@k9t)qF7ZW~Mq6Cf<(E zw?#NGSE3#@1Db(F2C8yu(fxn=egD6hq-UA|&A`86fYth5zlSBMyLG8Jx@&FJTT~K? oD { return color || '0xffffff'; }; + // Helper function to properly escape shell arguments + const escapeShellArg = (arg) => { + // If argument contains spaces, brackets, or other special characters, quote it + if (/[\s\[\]()$`"'\\|&;<>*?~]/.test(arg)) { + return `"${arg.replace(/"/g, '\\"')}"`; + } + return arg; + }; + + // Better text escaping function for FFmpeg drawtext filter + const escapeTextForDrawtext = (text) => { + return text + .replace(/\\/g, '\\\\\\\\') // Escape backslashes - needs 4 backslashes for proper escaping + .replace(/'/g, "\\'") // Escape single quotes + .replace(/:/g, '\\:') // Escape colons + .replace(/\[/g, '\\[') // Escape square brackets + .replace(/\]/g, '\\]') + .replace(/,/g, '\\,') // Escape commas + .replace(/;/g, '\\;') // Escape semicolons + .replace(/\|/g, '\\|') // Escape pipes + .replace(/\n/g, ' ') // Replace newlines with spaces + .replace(/\r/g, ' ') // Replace carriage returns with spaces + .replace(/\t/g, ' '); // Replace tabs with spaces + }; + const generateFFmpegCommand = useCallback( (is_string = true, useLocalFiles = false) => { showConsoleLogs && console.log('🎬 STARTING FFmpeg generation'); @@ -102,9 +127,9 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { 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'; + return 'ffmpeg -f lavfi -i color=black:size=450x800:duration=1 -c:v libvpx-vp9 -t 1 output.mp4'; } else { - return ['-f', 'lavfi', '-i', 'color=black:size=450x800:duration=1', '-c:v', 'libx264', '-t', '1', 'output.mp4']; + return ['-f', 'lavfi', '-i', 'color=black:size=450x800:duration=1', '-c:v', 'libvpx-vp9', '-t', '1', 'output.mp4']; } } @@ -130,7 +155,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { let videoLayer = 'base'; - // FIXED: Sort all visual elements by layer, then process in layer order + // Sort all visual elements by layer, then process in layer order const allVisualElements = [ ...videos.map((v, i) => ({ ...v, elementType: 'video', originalIndex: i })), ...images.map((img, i) => ({ ...img, elementType: 'image', originalIndex: i })), @@ -226,14 +251,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { console.log(`📝 Text ${i} (Layer ${t.layer || 0}) - Position: (${t.x}, ${t.y}) Text: "${t.text.substring(0, 30)}..."`); // Better text escaping for FFmpeg - const escapedText = t.text - .replace(/\\/g, '\\\\') // Escape backslashes first - .replace(/'/g, is_string ? "\\'" : "'") // Escape quotes - .replace(/:/g, '\\:') // Escape colons - .replace(/\[/g, '\\[') // Escape square brackets - .replace(/\]/g, '\\]') - .replace(/,/g, '\\,') // Escape commas - .replace(/;/g, '\\;'); // Escape semicolons + const escapedText = escapeTextForDrawtext(t.text); // Get the appropriate font file path const fontFilePath = getFontFilePath(t.fontFamily, t.fontWeight, t.fontStyle); @@ -248,7 +266,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { const borderWidth = Math.max(0, t.strokeWidth || 0); // Ensure non-negative // Build drawtext filter with proper border handling - // For centering: use (w-tw)/2 for x and adjust y as needed + // FIXED: Wrap enable parameter without quotes to avoid truncation let drawTextFilter = `[${videoLayer}]drawtext=fontfile=/${fontFileName}:text='${escapedText}':x=(w-tw)/2:y=${centerY}:fontsize=${t.fontSize}:fontcolor=${fontColor}`; // Only add border if strokeWidth > 0 @@ -256,7 +274,8 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { drawTextFilter += `:borderw=${borderWidth}:bordercolor=${borderColor}`; } - drawTextFilter += `:enable='between(t,${t.startTime},${t.startTime + t.duration})'[t${i}]`; + // FIXED: Don't wrap enable parameter in quotes - this was causing the truncation + drawTextFilter += `:enable=between(t\\,${t.startTime}\\,${t.startTime + t.duration})[t${i}]`; showConsoleLogs && console.log(`Text filter ${i}:`, drawTextFilter); filters.push(drawTextFilter); @@ -302,7 +321,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { `[${videoLayer}]`, ...audioArgs, '-c:v', - 'libx264', + 'libvpx-vp9', '-pix_fmt', 'yuv420p', '-r', @@ -316,27 +335,32 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { let inputStrings = []; videos.forEach((v, i) => { - inputStrings.push(`-i "${useLocalFiles ? `input_video_${i}.webm` : v.source_webm}"`); + inputStrings.push(`-i ${escapeShellArg(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}"`); + inputStrings.push(`-loop 1 -t ${img.duration} -i ${escapeShellArg(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`; + const audioMap = audioArgs.length > 0 ? ` ${audioArgs.map((arg) => escapeShellArg(arg)).join(' ')}` : ''; + const command = `ffmpeg ${inputs} -filter_complex ${escapeShellArg(filterComplex)} -map ${escapeShellArg(`[${videoLayer}]`)}${audioMap} -c:v libvpx-vp9 -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')); + showConsoleLogs && + console.log( + '🎵 FINAL ARGS HAVE AUDIO:', + finalArgs.some((arg) => typeof arg === 'string' && arg.includes('atrim')) && + finalArgs.some((arg) => typeof arg === 'string' && arg.includes('audio_final')), + ); return finalArgs; } }, - [timelineElements, dimensions, totalDuration], + [timelineElements, dimensions, totalDuration, showConsoleLogs], ); const ffmpegCommand = useMemo(() => { @@ -347,7 +371,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { showConsoleLogs && console.log('🎬 FFMPEG COMMAND GENERATED:'); showConsoleLogs && console.log('Command:', ffmpegCommand); navigator.clipboard.writeText(ffmpegCommand); - }, [ffmpegCommand]); + }, [ffmpegCommand, showConsoleLogs]); const exportVideo = async () => { setIsExporting(true); @@ -367,7 +391,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { showConsoleLogs && console.log('FFmpeg Log:', message); }); - const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm'; + const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.15/dist/esm'; const coreURL = `${baseURL}/ffmpeg-core.js`; const wasmURL = `${baseURL}/ffmpeg-core.wasm`; @@ -395,7 +419,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { // Add Arial font (fallback) fontsToLoad.set('arial.ttf', 'https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf'); - // Add fonts used by text elements - FIXED: use the actual font paths from getFontFilePath + // Add fonts used by text elements timelineElements .filter((el) => el.type === 'text') .forEach((text) => { @@ -404,7 +428,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => { // Only add if not already in map and not arial.ttf if (fontFileName !== 'arial.ttf' && !fontsToLoad.has(fontFileName)) { - fontsToLoad.set(fontFileName, fontFilePath); // Use the actual path, not reconstructed + fontsToLoad.set(fontFileName, fontFilePath); } });