368 lines
15 KiB
JavaScript
368 lines
15 KiB
JavaScript
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
|
import { fetchFile, toBlobURL } from '@ffmpeg/util';
|
|
import { useCallback, useMemo, useState } from 'react';
|
|
|
|
// Font configuration mapping
|
|
const FONT_CONFIG = {
|
|
Montserrat: {
|
|
normal: '/fonts/Montserrat/static/Montserrat-Regular.ttf',
|
|
bold: '/fonts/Montserrat/static/Montserrat-Bold.ttf',
|
|
italic: '/fonts/Montserrat/static/Montserrat-Italic.ttf',
|
|
boldItalic: '/fonts/Montserrat/static/Montserrat-BoldItalic.ttf',
|
|
},
|
|
Arial: {
|
|
normal: '/arial.ttf',
|
|
bold: '/arial.ttf',
|
|
italic: '/arial.ttf',
|
|
boldItalic: '/arial.ttf',
|
|
},
|
|
};
|
|
|
|
const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
|
|
const [showConsoleLogs] = useState(false);
|
|
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
const [exportProgress, setExportProgress] = useState(0);
|
|
const [exportStatus, setExportStatus] = useState('');
|
|
|
|
// Helper function to get font file path based on font family and style
|
|
const getFontFilePath = (fontFamily, fontWeight, fontStyle) => {
|
|
const family = fontFamily || 'Arial';
|
|
const config = FONT_CONFIG[family] || FONT_CONFIG.Arial;
|
|
|
|
const isBold = fontWeight === 'bold' || fontWeight === 700;
|
|
const isItalic = fontStyle === 'italic';
|
|
|
|
if (isBold && isItalic) {
|
|
return config.boldItalic;
|
|
} else if (isBold) {
|
|
return config.bold;
|
|
} else if (isItalic) {
|
|
return config.italic;
|
|
} else {
|
|
return config.normal;
|
|
}
|
|
};
|
|
|
|
const generateFFmpegCommand = useCallback(
|
|
(is_string = true, useLocalFiles = false) => {
|
|
showConsoleLogs && console.log('🎬 STARTING FFmpeg generation');
|
|
|
|
const videos = timelineElements.filter((el) => el.type === 'video');
|
|
const images = timelineElements.filter((el) => el.type === 'image');
|
|
const texts = timelineElements.filter((el) => el.type === 'text');
|
|
|
|
showConsoleLogs && console.log('Videos found:', videos.length);
|
|
showConsoleLogs && console.log('Images found:', images.length);
|
|
showConsoleLogs && console.log('Texts found:', texts.length);
|
|
|
|
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';
|
|
} else {
|
|
return ['-f', 'lavfi', '-i', 'color=black:size=450x800:duration=1', '-c:v', 'libx264', '-t', '1', 'output.mp4'];
|
|
}
|
|
}
|
|
|
|
let inputArgs = [];
|
|
let inputIndex = 0;
|
|
|
|
// Add video inputs
|
|
videos.forEach((v, i) => {
|
|
inputArgs.push('-i');
|
|
inputArgs.push(useLocalFiles ? `input_video_${i}.webm` : v.source_webm);
|
|
inputIndex++;
|
|
});
|
|
|
|
// Add image inputs with loop and duration
|
|
images.forEach((img, i) => {
|
|
inputArgs.push('-loop', '1', '-t', img.duration.toString(), '-i');
|
|
inputArgs.push(useLocalFiles ? `input_image_${i}.jpg` : img.source);
|
|
inputIndex++;
|
|
});
|
|
|
|
let filters = [];
|
|
filters.push(`color=black:size=${dimensions.width}x${dimensions.height}:duration=${totalDuration}[base]`);
|
|
|
|
let videoLayer = 'base';
|
|
let currentInputIndex = 0;
|
|
|
|
// Process video elements
|
|
videos.forEach((v, i) => {
|
|
filters.push(`[${currentInputIndex}: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`;
|
|
currentInputIndex++;
|
|
});
|
|
|
|
// Process image elements
|
|
images.forEach((img, i) => {
|
|
const imgInputIndex = currentInputIndex;
|
|
filters.push(`[${imgInputIndex}:v]scale=${Math.round(img.width)}:${Math.round(img.height)}[img${i}_scale]`);
|
|
filters.push(
|
|
`[${videoLayer}][img${i}_scale]overlay=${Math.round(img.x)}:${Math.round(img.y)}:enable='between(t,${img.startTime},${
|
|
img.startTime + img.duration
|
|
})'[img${i}_out]`,
|
|
);
|
|
videoLayer = `img${i}_out`;
|
|
currentInputIndex++;
|
|
});
|
|
|
|
showConsoleLogs && console.log('🎵 PROCESSING AUDIO FOR', videos.length, 'VIDEOS');
|
|
|
|
let audioOutputs = [];
|
|
videos.forEach((v, i) => {
|
|
const delay = Math.round(v.startTime * 1000);
|
|
showConsoleLogs && 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'];
|
|
}
|
|
|
|
showConsoleLogs && console.log('🎵 Audio args:', audioArgs);
|
|
|
|
// Process text elements with proper font support
|
|
texts.forEach((t, i) => {
|
|
const escapedText = t.text.replace(/'/g, is_string ? "\\'" : "'").replace(/:/g, '\\:');
|
|
|
|
// Get the appropriate font file path
|
|
const fontFilePath = getFontFilePath(t.fontFamily, t.fontWeight, t.fontStyle);
|
|
const fontFileName = fontFilePath.split('/').pop();
|
|
|
|
// Center the text: x position is the center point, y is adjusted for baseline
|
|
const centerX = Math.round(t.x);
|
|
const centerY = Math.round(t.y + t.fontSize * 0.3); // Adjust for text baseline
|
|
|
|
filters.push(
|
|
`[${videoLayer}]drawtext=fontfile=/${fontFileName}:text='${escapedText}':x=${centerX}:y=${centerY}:fontsize=${t.fontSize}:fontcolor=${t.fill}:borderw=${t.strokeWidth}:bordercolor=${
|
|
t.stroke
|
|
}:text_align=center:enable='between(t,${t.startTime},${t.startTime + t.duration})'[t${i}]`,
|
|
);
|
|
videoLayer = `t${i}`;
|
|
});
|
|
|
|
const filterComplex = filters.join('; ');
|
|
showConsoleLogs && 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) {
|
|
let inputStrings = [];
|
|
|
|
videos.forEach((v, i) => {
|
|
inputStrings.push(`-i "${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}"`);
|
|
});
|
|
|
|
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`;
|
|
|
|
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'));
|
|
|
|
return finalArgs;
|
|
}
|
|
},
|
|
[timelineElements, dimensions, totalDuration],
|
|
);
|
|
|
|
const ffmpegCommand = useMemo(() => {
|
|
return generateFFmpegCommand(true, false);
|
|
}, [generateFFmpegCommand]);
|
|
|
|
const copyFFmpegCommand = useCallback(() => {
|
|
showConsoleLogs && console.log('🎬 FFMPEG COMMAND GENERATED:');
|
|
showConsoleLogs && 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 }) => {
|
|
showConsoleLogs && 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`;
|
|
|
|
showConsoleLogs && console.log('Converting JS coreURL...');
|
|
const coreBlobURL = await toBlobURL(coreURL, 'text/javascript');
|
|
showConsoleLogs && console.log('JS coreURL ready:', coreBlobURL);
|
|
|
|
showConsoleLogs && console.log('Converting WASM URL...');
|
|
const wasmBlobURL = await toBlobURL(wasmURL, 'application/wasm');
|
|
showConsoleLogs && console.log('WASM URL ready:', wasmBlobURL);
|
|
|
|
showConsoleLogs && console.log('Calling ffmpeg.load...');
|
|
await ffmpeg.load({
|
|
coreURL: coreBlobURL,
|
|
wasmURL: wasmBlobURL,
|
|
});
|
|
showConsoleLogs && console.log('FFmpeg loaded!');
|
|
setExportProgress(10);
|
|
|
|
setExportStatus('Loading fonts...');
|
|
|
|
// Load all required fonts
|
|
const fontsToLoad = new Set();
|
|
|
|
// Add Arial font (fallback)
|
|
fontsToLoad.add('arial.ttf');
|
|
|
|
// Add fonts used by text elements
|
|
timelineElements
|
|
.filter((el) => el.type === 'text')
|
|
.forEach((text) => {
|
|
const fontFilePath = getFontFilePath(text.fontFamily, text.fontWeight, text.fontStyle);
|
|
const fontFileName = fontFilePath.split('/').pop();
|
|
fontsToLoad.add(fontFileName);
|
|
});
|
|
|
|
// Load each unique font
|
|
let fontProgress = 0;
|
|
for (const fontFile of fontsToLoad) {
|
|
try {
|
|
if (fontFile === 'arial.ttf') {
|
|
await ffmpeg.writeFile(
|
|
'arial.ttf',
|
|
await fetchFile('https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf'),
|
|
);
|
|
} else {
|
|
// Load Montserrat fonts from local filesystem
|
|
const fontPath = `/fonts/Montserrat/static/${fontFile}`;
|
|
await ffmpeg.writeFile(fontFile, await fetchFile(fontPath));
|
|
}
|
|
fontProgress++;
|
|
setExportProgress(10 + Math.round((fontProgress / fontsToLoad.size) * 10));
|
|
} catch (error) {
|
|
console.warn(`Failed to load font ${fontFile}, falling back to arial.ttf:`, error);
|
|
// If font loading fails, we'll use arial.ttf as fallback
|
|
}
|
|
}
|
|
|
|
showConsoleLogs && console.log('Fonts loaded!');
|
|
setExportProgress(20);
|
|
|
|
setExportStatus('Downloading media...');
|
|
const videos = timelineElements.filter((el) => el.type === 'video');
|
|
const images = timelineElements.filter((el) => el.type === 'image');
|
|
const totalMedia = videos.length + images.length;
|
|
|
|
let mediaProgress = 0;
|
|
|
|
// Download videos
|
|
for (let i = 0; i < videos.length; i++) {
|
|
await ffmpeg.writeFile(`input_video_${i}.webm`, await fetchFile(videos[i].source_webm));
|
|
mediaProgress++;
|
|
setExportProgress(20 + Math.round((mediaProgress / totalMedia) * 40));
|
|
}
|
|
|
|
// Download images
|
|
for (let i = 0; i < images.length; i++) {
|
|
await ffmpeg.writeFile(`input_image_${i}.jpg`, await fetchFile(images[i].source));
|
|
mediaProgress++;
|
|
setExportProgress(20 + Math.round((mediaProgress / totalMedia) * 40));
|
|
}
|
|
|
|
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;
|