Update
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { useMitt } from '@/plugins/MittContext';
|
||||
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile, toBlobURL } from '@ffmpeg/util';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import useVideoExport from './video-export';
|
||||
import VideoPreview from './video-preview';
|
||||
|
||||
const VideoEditor = ({ width, height }) => {
|
||||
@@ -91,13 +90,8 @@ const VideoEditor = ({ width, height }) => {
|
||||
]);
|
||||
|
||||
const lastUpdateRef = useRef(0);
|
||||
const ffmpegRef = useRef(new FFmpeg());
|
||||
const emitter = useMitt();
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState(0);
|
||||
const [exportStatus, setExportStatus] = useState('');
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [videoElements, setVideoElements] = useState({});
|
||||
@@ -118,126 +112,13 @@ const VideoEditor = ({ width, height }) => {
|
||||
|
||||
const totalDuration = Math.max(...timelineElements.map((el) => el.startTime + el.duration));
|
||||
|
||||
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);
|
||||
// Use the FFmpeg hook
|
||||
const { isExporting, exportProgress, exportStatus, ffmpegCommand, copyFFmpegCommand, exportVideo } = useVideoExport({
|
||||
timelineElements,
|
||||
dimensions,
|
||||
totalDuration,
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const videoEls = {};
|
||||
const videoElementsData = timelineElements.filter((el) => el.type === 'video');
|
||||
@@ -379,94 +260,6 @@ const VideoEditor = ({ width, height }) => {
|
||||
}
|
||||
}, [isPlaying, videoElements]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveElements = useCallback(
|
||||
(time) => {
|
||||
return timelineElements.filter((element) => {
|
||||
|
||||
Reference in New Issue
Block a user