This commit is contained in:
ct
2025-06-21 10:35:26 +08:00
parent 8e58f85860
commit 6a77a8b8c2
2 changed files with 48 additions and 24 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -73,6 +73,31 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
return color || '0xffffff'; 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( const generateFFmpegCommand = useCallback(
(is_string = true, useLocalFiles = false) => { (is_string = true, useLocalFiles = false) => {
showConsoleLogs && console.log('🎬 STARTING FFmpeg generation'); showConsoleLogs && console.log('🎬 STARTING FFmpeg generation');
@@ -102,9 +127,9 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
if (videos.length === 0 && images.length === 0) { if (videos.length === 0 && images.length === 0) {
if (is_string) { 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 { } 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'; 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 = [ const allVisualElements = [
...videos.map((v, i) => ({ ...v, elementType: 'video', originalIndex: i })), ...videos.map((v, i) => ({ ...v, elementType: 'video', originalIndex: i })),
...images.map((img, i) => ({ ...img, elementType: 'image', 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)}..."`); console.log(`📝 Text ${i} (Layer ${t.layer || 0}) - Position: (${t.x}, ${t.y}) Text: "${t.text.substring(0, 30)}..."`);
// Better text escaping for FFmpeg // Better text escaping for FFmpeg
const escapedText = t.text const escapedText = escapeTextForDrawtext(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
// Get the appropriate font file path // Get the appropriate font file path
const fontFilePath = getFontFilePath(t.fontFamily, t.fontWeight, t.fontStyle); 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 const borderWidth = Math.max(0, t.strokeWidth || 0); // Ensure non-negative
// Build drawtext filter with proper border handling // 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}`; 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 // Only add border if strokeWidth > 0
@@ -256,7 +274,8 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
drawTextFilter += `:borderw=${borderWidth}:bordercolor=${borderColor}`; 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); showConsoleLogs && console.log(`Text filter ${i}:`, drawTextFilter);
filters.push(drawTextFilter); filters.push(drawTextFilter);
@@ -302,7 +321,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
`[${videoLayer}]`, `[${videoLayer}]`,
...audioArgs, ...audioArgs,
'-c:v', '-c:v',
'libx264', 'libvpx-vp9',
'-pix_fmt', '-pix_fmt',
'yuv420p', 'yuv420p',
'-r', '-r',
@@ -316,27 +335,32 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
let inputStrings = []; let inputStrings = [];
videos.forEach((v, i) => { 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) => { 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 inputs = inputStrings.join(' ');
const audioMap = audioArgs.length > 0 ? ` ${audioArgs.join(' ')}` : ''; const audioMap = audioArgs.length > 0 ? ` ${audioArgs.map((arg) => escapeShellArg(arg)).join(' ')}` : '';
const command = `ffmpeg ${inputs} -filter_complex "${filterComplex}" -map "[${videoLayer}]"${audioMap} -c:v libx264 -pix_fmt yuv420p -r 30 -t ${totalDuration} output.mp4`; 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')); showConsoleLogs && console.log('🎵 FINAL COMMAND HAS AUDIO:', command.includes('atrim') && command.includes('audio_final'));
return command; return command;
} else { } 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; return finalArgs;
} }
}, },
[timelineElements, dimensions, totalDuration], [timelineElements, dimensions, totalDuration, showConsoleLogs],
); );
const ffmpegCommand = useMemo(() => { const ffmpegCommand = useMemo(() => {
@@ -347,7 +371,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
showConsoleLogs && console.log('🎬 FFMPEG COMMAND GENERATED:'); showConsoleLogs && console.log('🎬 FFMPEG COMMAND GENERATED:');
showConsoleLogs && console.log('Command:', ffmpegCommand); showConsoleLogs && console.log('Command:', ffmpegCommand);
navigator.clipboard.writeText(ffmpegCommand); navigator.clipboard.writeText(ffmpegCommand);
}, [ffmpegCommand]); }, [ffmpegCommand, showConsoleLogs]);
const exportVideo = async () => { const exportVideo = async () => {
setIsExporting(true); setIsExporting(true);
@@ -367,7 +391,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
showConsoleLogs && console.log('FFmpeg Log:', message); 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 coreURL = `${baseURL}/ffmpeg-core.js`;
const wasmURL = `${baseURL}/ffmpeg-core.wasm`; const wasmURL = `${baseURL}/ffmpeg-core.wasm`;
@@ -395,7 +419,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
// Add Arial font (fallback) // Add Arial font (fallback)
fontsToLoad.set('arial.ttf', 'https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf'); 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 timelineElements
.filter((el) => el.type === 'text') .filter((el) => el.type === 'text')
.forEach((text) => { .forEach((text) => {
@@ -404,7 +428,7 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
// Only add if not already in map and not arial.ttf // Only add if not already in map and not arial.ttf
if (fontFileName !== 'arial.ttf' && !fontsToLoad.has(fontFileName)) { if (fontFileName !== 'arial.ttf' && !fontsToLoad.has(fontFileName)) {
fontsToLoad.set(fontFileName, fontFilePath); // Use the actual path, not reconstructed fontsToLoad.set(fontFileName, fontFilePath);
} }
}); });