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

View File

@@ -73,6 +73,31 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
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);
}
});