Update
This commit is contained in:
@@ -25,8 +25,8 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
duration: 5,
|
duration: 5,
|
||||||
x: 50,
|
x: 50,
|
||||||
y: 50,
|
y: 50,
|
||||||
width: 300, // Will be updated when video loads
|
width: 300,
|
||||||
height: 200, // Will be updated when video loads
|
height: 200,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
@@ -41,8 +41,8 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
duration: 4,
|
duration: 4,
|
||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
width: 250, // Will be updated when video loads
|
width: 250,
|
||||||
height: 150, // Will be updated when video loads
|
height: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
@@ -57,8 +57,8 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
duration: 6,
|
duration: 6,
|
||||||
x: 200,
|
x: 200,
|
||||||
y: 200,
|
y: 200,
|
||||||
width: 280, // Will be updated when video loads
|
width: 280,
|
||||||
height: 180, // Will be updated when video loads
|
height: 180,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
@@ -91,10 +91,7 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const lastUpdateRef = useRef(0);
|
const lastUpdateRef = useRef(0);
|
||||||
|
|
||||||
// FFmpeg WASM states
|
|
||||||
const ffmpegRef = useRef(new FFmpeg());
|
const ffmpegRef = useRef(new FFmpeg());
|
||||||
|
|
||||||
const emitter = useMitt();
|
const emitter = useMitt();
|
||||||
|
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
@@ -106,8 +103,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
const [videoElements, setVideoElements] = useState({});
|
const [videoElements, setVideoElements] = useState({});
|
||||||
const [loadedVideos, setLoadedVideos] = useState(new Set());
|
const [loadedVideos, setLoadedVideos] = useState(new Set());
|
||||||
const [status, setStatus] = useState('Loading videos...');
|
const [status, setStatus] = useState('Loading videos...');
|
||||||
|
|
||||||
// Track which videos should be playing - this is the key optimization
|
|
||||||
const [videoStates, setVideoStates] = useState({});
|
const [videoStates, setVideoStates] = useState({});
|
||||||
|
|
||||||
const animationRef = useRef(null);
|
const animationRef = useRef(null);
|
||||||
@@ -119,12 +114,10 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVideoIsPlaying(isPlaying);
|
setVideoIsPlaying(isPlaying);
|
||||||
}, [isPlaying]);
|
}, [isPlaying, setVideoIsPlaying]);
|
||||||
|
|
||||||
// Calculate total timeline duration
|
|
||||||
const totalDuration = Math.max(...timelineElements.map((el) => el.startTime + el.duration));
|
const totalDuration = Math.max(...timelineElements.map((el) => el.startTime + el.duration));
|
||||||
|
|
||||||
// Generate FFmpeg command - COMPLETE VERSION
|
|
||||||
const generateFFmpegCommand = useCallback(
|
const generateFFmpegCommand = useCallback(
|
||||||
(is_string = true, useLocalFiles = false) => {
|
(is_string = true, useLocalFiles = false) => {
|
||||||
console.log('🎬 STARTING FFmpeg generation');
|
console.log('🎬 STARTING FFmpeg generation');
|
||||||
@@ -142,20 +135,15 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build inputs
|
|
||||||
let inputArgs = [];
|
let inputArgs = [];
|
||||||
videos.forEach((v, i) => {
|
videos.forEach((v, i) => {
|
||||||
inputArgs.push('-i');
|
inputArgs.push('-i');
|
||||||
inputArgs.push(useLocalFiles ? `input${i}.webm` : v.source);
|
inputArgs.push(useLocalFiles ? `input${i}.webm` : v.source);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build filter parts array
|
|
||||||
let filters = [];
|
let filters = [];
|
||||||
|
|
||||||
// Base canvas
|
|
||||||
filters.push(`color=black:size=${dimensions.width}x${dimensions.height}:duration=${totalDuration}[base]`);
|
filters.push(`color=black:size=${dimensions.width}x${dimensions.height}:duration=${totalDuration}[base]`);
|
||||||
|
|
||||||
// Process video streams
|
|
||||||
let videoLayer = 'base';
|
let videoLayer = 'base';
|
||||||
videos.forEach((v, i) => {
|
videos.forEach((v, i) => {
|
||||||
filters.push(`[${i}:v]trim=start=${v.inPoint}:duration=${v.duration},setpts=PTS-STARTPTS[v${i}_trim]`);
|
filters.push(`[${i}:v]trim=start=${v.inPoint}:duration=${v.duration},setpts=PTS-STARTPTS[v${i}_trim]`);
|
||||||
@@ -168,7 +156,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
videoLayer = `v${i}_out`;
|
videoLayer = `v${i}_out`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// AUDIO PROCESSING - EXPLICIT AND COMPLETE
|
|
||||||
console.log('🎵 PROCESSING AUDIO FOR', videos.length, 'VIDEOS');
|
console.log('🎵 PROCESSING AUDIO FOR', videos.length, 'VIDEOS');
|
||||||
|
|
||||||
let audioOutputs = [];
|
let audioOutputs = [];
|
||||||
@@ -179,7 +166,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
audioOutputs.push(`[a${i}]`);
|
audioOutputs.push(`[a${i}]`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Audio mixing
|
|
||||||
let audioArgs = [];
|
let audioArgs = [];
|
||||||
if (audioOutputs.length === 1) {
|
if (audioOutputs.length === 1) {
|
||||||
filters.push(`[a0]apad=pad_dur=${totalDuration}[audio_final]`);
|
filters.push(`[a0]apad=pad_dur=${totalDuration}[audio_final]`);
|
||||||
@@ -191,7 +177,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
|
|
||||||
console.log('🎵 Audio args:', audioArgs);
|
console.log('🎵 Audio args:', audioArgs);
|
||||||
|
|
||||||
// Add text overlays
|
|
||||||
texts.forEach((t, i) => {
|
texts.forEach((t, i) => {
|
||||||
const escapedText = t.text.replace(/'/g, is_string ? "\\'" : "'").replace(/:/g, '\\:');
|
const escapedText = t.text.replace(/'/g, is_string ? "\\'" : "'").replace(/:/g, '\\:');
|
||||||
|
|
||||||
@@ -204,11 +189,10 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
);
|
);
|
||||||
videoLayer = `t${i}`;
|
videoLayer = `t${i}`;
|
||||||
});
|
});
|
||||||
// Join all filter parts
|
|
||||||
const filterComplex = filters.join('; ');
|
const filterComplex = filters.join('; ');
|
||||||
console.log('🎵 Filter includes atrim:', filterComplex.includes('atrim'));
|
console.log('🎵 Filter includes atrim:', filterComplex.includes('atrim'));
|
||||||
|
|
||||||
// Build final arguments
|
|
||||||
const finalArgs = [
|
const finalArgs = [
|
||||||
...inputArgs,
|
...inputArgs,
|
||||||
'-filter_complex',
|
'-filter_complex',
|
||||||
@@ -228,7 +212,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (is_string) {
|
if (is_string) {
|
||||||
// Build final command string
|
|
||||||
const inputs = videos.map((v, i) => `-i "${useLocalFiles ? `input${i}.webm` : v.source}"`).join(' ');
|
const inputs = videos.map((v, i) => `-i "${useLocalFiles ? `input${i}.webm` : v.source}"`).join(' ');
|
||||||
const audioMap = audioArgs.length > 0 ? ` ${audioArgs.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 command = `ffmpeg ${inputs} -filter_complex "${filterComplex}" -map "[${videoLayer}]"${audioMap} -c:v libx264 -pix_fmt yuv420p -r 30 -t ${totalDuration} output.mp4`;
|
||||||
@@ -245,20 +228,16 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
[timelineElements, dimensions, totalDuration],
|
[timelineElements, dimensions, totalDuration],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize the FFmpeg command
|
|
||||||
const ffmpegCommand = useMemo(() => {
|
const ffmpegCommand = useMemo(() => {
|
||||||
return generateFFmpegCommand(true, false);
|
return generateFFmpegCommand(true, false);
|
||||||
}, [generateFFmpegCommand]);
|
}, [generateFFmpegCommand]);
|
||||||
|
|
||||||
// Memoize the copy function
|
|
||||||
const copyFFmpegCommand = useCallback(() => {
|
const copyFFmpegCommand = useCallback(() => {
|
||||||
console.log('🎬 FFMPEG COMMAND GENERATED:');
|
console.log('🎬 FFMPEG COMMAND GENERATED:');
|
||||||
console.log('Command:', ffmpegCommand);
|
console.log('Command:', ffmpegCommand);
|
||||||
navigator.clipboard.writeText(ffmpegCommand);
|
navigator.clipboard.writeText(ffmpegCommand);
|
||||||
}, [ffmpegCommand]);
|
}, [ffmpegCommand]);
|
||||||
|
|
||||||
// Create video elements
|
|
||||||
// Replace your existing useEffect with this:
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const videoEls = {};
|
const videoEls = {};
|
||||||
const videoElementsData = timelineElements.filter((el) => el.type === 'video');
|
const videoElementsData = timelineElements.filter((el) => el.type === 'video');
|
||||||
@@ -282,13 +261,11 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
video.appendChild(sourceMov);
|
video.appendChild(sourceMov);
|
||||||
video.appendChild(sourceWebM);
|
video.appendChild(sourceWebM);
|
||||||
|
|
||||||
// Load poster separately
|
|
||||||
const posterImg = new Image();
|
const posterImg = new Image();
|
||||||
posterImg.crossOrigin = 'anonymous';
|
posterImg.crossOrigin = 'anonymous';
|
||||||
posterImg.src = element.poster;
|
posterImg.src = element.poster;
|
||||||
|
|
||||||
posterImg.onload = () => {
|
posterImg.onload = () => {
|
||||||
// Calculate scaling for poster
|
|
||||||
const maxWidth = dimensions.width;
|
const maxWidth = dimensions.width;
|
||||||
const maxHeight = dimensions.height;
|
const maxHeight = dimensions.height;
|
||||||
const posterWidth = posterImg.naturalWidth;
|
const posterWidth = posterImg.naturalWidth;
|
||||||
@@ -309,7 +286,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
const centeredX = (maxWidth - scaledWidth) / 2;
|
const centeredX = (maxWidth - scaledWidth) / 2;
|
||||||
const centeredY = (maxHeight - scaledHeight) / 2;
|
const centeredY = (maxHeight - scaledHeight) / 2;
|
||||||
|
|
||||||
// Update timeline element with poster
|
|
||||||
setTimelineElements((prev) =>
|
setTimelineElements((prev) =>
|
||||||
prev.map((el) => {
|
prev.map((el) => {
|
||||||
if (el.id === element.id && el.type === 'video') {
|
if (el.id === element.id && el.type === 'video') {
|
||||||
@@ -319,8 +295,8 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
y: centeredY,
|
y: centeredY,
|
||||||
width: scaledWidth,
|
width: scaledWidth,
|
||||||
height: scaledHeight,
|
height: scaledHeight,
|
||||||
posterImage: posterImg, // Store poster reference
|
posterImage: posterImg,
|
||||||
isVideoPoster: true, // Flag to indicate poster is loaded
|
isVideoPoster: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
@@ -335,14 +311,13 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
video.addEventListener('loadedmetadata', () => {
|
video.addEventListener('loadedmetadata', () => {
|
||||||
// Video metadata loaded - store video reference
|
|
||||||
setTimelineElements((prev) =>
|
setTimelineElements((prev) =>
|
||||||
prev.map((el) => {
|
prev.map((el) => {
|
||||||
if (el.id === element.id && el.type === 'video') {
|
if (el.id === element.id && el.type === 'video') {
|
||||||
return {
|
return {
|
||||||
...el,
|
...el,
|
||||||
videoElement: video, // Store video reference
|
videoElement: video,
|
||||||
isVideoReady: true, // Flag to indicate video is ready
|
isVideoReady: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
@@ -369,9 +344,8 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
video.load();
|
video.load();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [dimensions.width, dimensions.height]); // Add dimensions as dependency
|
}, [dimensions.width, dimensions.height]);
|
||||||
|
|
||||||
// Update status when videos load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const videoCount = timelineElements.filter((el) => el.type === 'video').length;
|
const videoCount = timelineElements.filter((el) => el.type === 'video').length;
|
||||||
if (loadedVideos.size === videoCount && videoCount > 0) {
|
if (loadedVideos.size === videoCount && videoCount > 0) {
|
||||||
@@ -383,12 +357,12 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
}
|
}
|
||||||
}, [loadedVideos, timelineElements]);
|
}, [loadedVideos, timelineElements]);
|
||||||
|
|
||||||
|
// FIXED: Removed currentTime dependency to prevent excessive recreation
|
||||||
const handlePause = useCallback(() => {
|
const handlePause = useCallback(() => {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
pausedTimeRef.current = currentTime;
|
pausedTimeRef.current = currentTime;
|
||||||
|
|
||||||
// Pause and mute all videos when pausing timeline
|
|
||||||
Object.values(videoElements).forEach((video) => {
|
Object.values(videoElements).forEach((video) => {
|
||||||
if (!video.paused) {
|
if (!video.paused) {
|
||||||
video.pause();
|
video.pause();
|
||||||
@@ -396,14 +370,14 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
video.muted = true;
|
video.muted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset video states tracking
|
|
||||||
setVideoStates({});
|
setVideoStates({});
|
||||||
|
|
||||||
if (animationRef.current) {
|
if (animationRef.current) {
|
||||||
animationRef.current.stop();
|
animationRef.current.stop();
|
||||||
|
animationRef.current = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isPlaying, currentTime, videoElements]);
|
}, [isPlaying, videoElements]);
|
||||||
|
|
||||||
const exportVideo = async () => {
|
const exportVideo = async () => {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
@@ -443,13 +417,11 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
console.log('FFmpeg loaded!');
|
console.log('FFmpeg loaded!');
|
||||||
setExportProgress(20);
|
setExportProgress(20);
|
||||||
|
|
||||||
// Write arial.ttf font into FFmpeg FS (fetch from GitHub)
|
|
||||||
setExportStatus('Loading font...');
|
setExportStatus('Loading font...');
|
||||||
await ffmpeg.writeFile('arial.ttf', await fetchFile('https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf'));
|
await ffmpeg.writeFile('arial.ttf', await fetchFile('https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf'));
|
||||||
console.log('Font loaded!');
|
console.log('Font loaded!');
|
||||||
setExportProgress(30);
|
setExportProgress(30);
|
||||||
|
|
||||||
// Download videos
|
|
||||||
setExportStatus('Downloading videos...');
|
setExportStatus('Downloading videos...');
|
||||||
const videos = timelineElements.filter((el) => el.type === 'video');
|
const videos = timelineElements.filter((el) => el.type === 'video');
|
||||||
|
|
||||||
@@ -458,18 +430,12 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
setExportProgress(30 + Math.round(((i + 1) / videos.length) * 30));
|
setExportProgress(30 + Math.round(((i + 1) / videos.length) * 30));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate your FFmpeg command, but be sure to include fontfile=/arial.ttf in all drawtext filters
|
|
||||||
setExportStatus('Processing video...');
|
setExportStatus('Processing video...');
|
||||||
let args = generateFFmpegCommand(false, true);
|
let args = generateFFmpegCommand(false, true);
|
||||||
|
|
||||||
// Example: if your command uses drawtext filters, add fontfile argument like:
|
|
||||||
// drawtext=fontfile=/arial.ttf:text='Your text':x=50:y=600:fontsize=24:fontcolor=white:borderw=1:bordercolor=black
|
|
||||||
// Make sure your generateFFmpegCommand function inserts this correctly.
|
|
||||||
|
|
||||||
setExportProgress(70);
|
setExportProgress(70);
|
||||||
await ffmpeg.exec(args);
|
await ffmpeg.exec(args);
|
||||||
|
|
||||||
// Download result
|
|
||||||
setExportStatus('Downloading...');
|
setExportStatus('Downloading...');
|
||||||
setExportProgress(90);
|
setExportProgress(90);
|
||||||
|
|
||||||
@@ -501,7 +467,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get currently active elements based on timeline position
|
|
||||||
const getActiveElements = useCallback(
|
const getActiveElements = useCallback(
|
||||||
(time) => {
|
(time) => {
|
||||||
return timelineElements.filter((element) => {
|
return timelineElements.filter((element) => {
|
||||||
@@ -512,10 +477,8 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
[timelineElements],
|
[timelineElements],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate which videos should be playing based on current time
|
|
||||||
const getDesiredVideoStates = useCallback(
|
const getDesiredVideoStates = useCallback(
|
||||||
(time) => {
|
(time) => {
|
||||||
// Accept time as parameter
|
|
||||||
const states = {};
|
const states = {};
|
||||||
timelineElements.forEach((element) => {
|
timelineElements.forEach((element) => {
|
||||||
if (element.type === 'video') {
|
if (element.type === 'video') {
|
||||||
@@ -525,10 +488,9 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
});
|
});
|
||||||
return states;
|
return states;
|
||||||
},
|
},
|
||||||
[timelineElements], // Removed dependency on currentTime
|
[timelineElements],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update video times based on timeline position - optimized to reduce seeking
|
|
||||||
const updateVideoTimes = useCallback(
|
const updateVideoTimes = useCallback(
|
||||||
(time) => {
|
(time) => {
|
||||||
timelineElements.forEach((element) => {
|
timelineElements.forEach((element) => {
|
||||||
@@ -540,7 +502,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
const relativeTime = time - element.startTime;
|
const relativeTime = time - element.startTime;
|
||||||
const videoTime = element.inPoint + relativeTime;
|
const videoTime = element.inPoint + relativeTime;
|
||||||
|
|
||||||
// Only seek if time difference is significant
|
|
||||||
if (Math.abs(video.currentTime - videoTime) > 0.5) {
|
if (Math.abs(video.currentTime - videoTime) > 0.5) {
|
||||||
video.currentTime = videoTime;
|
video.currentTime = videoTime;
|
||||||
}
|
}
|
||||||
@@ -551,13 +512,11 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
[timelineElements, videoElements],
|
[timelineElements, videoElements],
|
||||||
);
|
);
|
||||||
|
|
||||||
// OPTIMIZED: Manage video play/pause states only when needed
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPlaying) return;
|
if (!isPlaying) return;
|
||||||
|
|
||||||
const desiredStates = getDesiredVideoStates(currentTime);
|
const desiredStates = getDesiredVideoStates(currentTime);
|
||||||
|
|
||||||
// Smarter play/pause without excessive updates
|
|
||||||
Object.entries(desiredStates).forEach(([videoId, shouldPlay]) => {
|
Object.entries(desiredStates).forEach(([videoId, shouldPlay]) => {
|
||||||
const video = videoElements[videoId];
|
const video = videoElements[videoId];
|
||||||
const isCurrentlyPlaying = !video?.paused;
|
const isCurrentlyPlaying = !video?.paused;
|
||||||
@@ -576,137 +535,140 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
setVideoStates(desiredStates);
|
setVideoStates(desiredStates);
|
||||||
}, [currentTime, isPlaying, videoElements, getDesiredVideoStates]);
|
}, [currentTime, isPlaying, videoElements, getDesiredVideoStates]);
|
||||||
|
|
||||||
const animate = useCallback(() => {
|
// FIXED: Properly stop animation when not playing
|
||||||
if (!animationRef.current || !isPlaying) return;
|
useEffect(() => {
|
||||||
|
if (!isPlaying) {
|
||||||
const now = Date.now() / 1000;
|
if (animationRef.current) {
|
||||||
const newTime = pausedTimeRef.current + (now - startTimeRef.current);
|
animationRef.current.stop();
|
||||||
|
animationRef.current = null;
|
||||||
if (newTime >= totalDuration) {
|
}
|
||||||
handlePause();
|
|
||||||
handleSeek(0); // ⬅️ Reset timeline
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newTime - lastUpdateRef.current >= 0.05) {
|
let animationId;
|
||||||
lastUpdateRef.current = newTime;
|
let isRunning = true;
|
||||||
setCurrentTime(newTime);
|
|
||||||
updateVideoTimes(newTime);
|
|
||||||
|
|
||||||
if (layerRef.current) {
|
const animateFrame = () => {
|
||||||
layerRef.current.batchDraw();
|
if (!isRunning) return;
|
||||||
|
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const newTime = pausedTimeRef.current + (now - startTimeRef.current);
|
||||||
|
|
||||||
|
if (newTime >= totalDuration) {
|
||||||
|
handlePause();
|
||||||
|
handleSeek(0);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [isPlaying, totalDuration, updateVideoTimes, handlePause]);
|
|
||||||
|
|
||||||
// Start animation loop - using requestAnimationFrame for better performance
|
if (newTime - lastUpdateRef.current >= 0.05) {
|
||||||
useEffect(() => {
|
lastUpdateRef.current = newTime;
|
||||||
if (isPlaying) {
|
setCurrentTime(newTime);
|
||||||
let animationId;
|
updateVideoTimes(newTime);
|
||||||
|
|
||||||
const animateFrame = () => {
|
if (layerRef.current) {
|
||||||
animate();
|
layerRef.current.batchDraw();
|
||||||
animationId = requestAnimationFrame(animateFrame);
|
|
||||||
};
|
|
||||||
|
|
||||||
animationId = requestAnimationFrame(animateFrame);
|
|
||||||
animationRef.current = { stop: () => cancelAnimationFrame(animationId) };
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (animationRef.current) {
|
|
||||||
animationRef.current.stop();
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
}
|
|
||||||
}, [isPlaying, animate]);
|
|
||||||
|
|
||||||
const handlePlay = () => {
|
if (isRunning) {
|
||||||
|
animationId = requestAnimationFrame(animateFrame);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
startTimeRef.current = Date.now() / 1000;
|
||||||
|
animationId = requestAnimationFrame(animateFrame);
|
||||||
|
|
||||||
|
animationRef.current = {
|
||||||
|
stop: () => {
|
||||||
|
isRunning = false;
|
||||||
|
if (animationId) {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isRunning = false;
|
||||||
|
if (animationId) {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying, totalDuration, handlePause, updateVideoTimes]);
|
||||||
|
|
||||||
|
// FIXED: Stabilized handlers
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
if (!isPlaying) {
|
if (!isPlaying) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
startTimeRef.current = Date.now() / 1000;
|
startTimeRef.current = Date.now() / 1000;
|
||||||
lastUpdateRef.current = 0; // ✅ Reset debounce tracker
|
lastUpdateRef.current = 0;
|
||||||
setStatus('');
|
setStatus('');
|
||||||
}
|
}
|
||||||
};
|
}, [isPlaying]);
|
||||||
|
|
||||||
const handleSeek = (time) => {
|
const handleSeek = useCallback(
|
||||||
const clampedTime = Math.max(0, Math.min(time, totalDuration));
|
(time) => {
|
||||||
setCurrentTime(clampedTime);
|
const clampedTime = Math.max(0, Math.min(time, totalDuration));
|
||||||
pausedTimeRef.current = clampedTime;
|
setCurrentTime(clampedTime);
|
||||||
updateVideoTimes(clampedTime);
|
pausedTimeRef.current = clampedTime;
|
||||||
|
updateVideoTimes(clampedTime);
|
||||||
|
|
||||||
// Reset video states when seeking to force re-evaluation
|
setVideoStates({});
|
||||||
setVideoStates({});
|
|
||||||
|
|
||||||
if (layerRef.current) {
|
if (layerRef.current) {
|
||||||
layerRef.current.draw();
|
layerRef.current.draw();
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[totalDuration, updateVideoTimes],
|
||||||
|
);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = useCallback(() => {
|
||||||
handlePause();
|
handlePause();
|
||||||
handleSeek(0);
|
handleSeek(0);
|
||||||
lastUpdateRef.current = 0; // ✅ Reset debounce tracker
|
lastUpdateRef.current = 0;
|
||||||
|
|
||||||
// Ensure all videos are muted
|
|
||||||
Object.values(videoElements).forEach((video) => {
|
Object.values(videoElements).forEach((video) => {
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
});
|
});
|
||||||
};
|
}, [handlePause, handleSeek, videoElements]);
|
||||||
|
|
||||||
const activeElements = getActiveElements(currentTime);
|
const activeElements = getActiveElements(currentTime);
|
||||||
|
|
||||||
|
// FIXED: Added missing dependencies to event listeners
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
emitter.on('video-play', () => {
|
emitter.on('video-play', handlePlay);
|
||||||
console.log('video-play');
|
emitter.on('video-reset', handleReset);
|
||||||
handlePlay();
|
emitter.on('video-seek', handleSeek);
|
||||||
});
|
|
||||||
|
|
||||||
emitter.on('video-pause', () => {
|
|
||||||
console.log('video-pause');
|
|
||||||
handlePause();
|
|
||||||
});
|
|
||||||
|
|
||||||
emitter.on('video-seek', (time) => {
|
|
||||||
handleSeek(time);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('video-play');
|
emitter.off('video-play', handlePlay);
|
||||||
emitter.off('video-pause');
|
emitter.off('video-reset', handleReset);
|
||||||
emitter.off('video-seek');
|
emitter.off('video-seek', handleSeek);
|
||||||
};
|
};
|
||||||
}, [emitter]);
|
}, [emitter, handlePlay, handleReset, handleSeek]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: dimensions.width, height: dimensions.height }} className="rounded-3xl">
|
<div style={{ width: dimensions.width, height: dimensions.height }} className="rounded-3xl">
|
||||||
<VideoPreview
|
<VideoPreview
|
||||||
// Dimensions
|
|
||||||
dimensions={dimensions}
|
dimensions={dimensions}
|
||||||
// Timeline state
|
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
totalDuration={totalDuration}
|
totalDuration={totalDuration}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
status={status}
|
status={status}
|
||||||
// Export state
|
|
||||||
isExporting={isExporting}
|
isExporting={isExporting}
|
||||||
exportProgress={exportProgress}
|
exportProgress={exportProgress}
|
||||||
exportStatus={exportStatus}
|
exportStatus={exportStatus}
|
||||||
// Data
|
|
||||||
timelineElements={timelineElements}
|
timelineElements={timelineElements}
|
||||||
activeElements={activeElements}
|
activeElements={activeElements}
|
||||||
videoElements={videoElements}
|
videoElements={videoElements}
|
||||||
loadedVideos={loadedVideos}
|
loadedVideos={loadedVideos}
|
||||||
videoStates={videoStates}
|
videoStates={videoStates}
|
||||||
ffmpegCommand={ffmpegCommand}
|
ffmpegCommand={ffmpegCommand}
|
||||||
// Event handlers
|
|
||||||
handlePlay={handlePlay}
|
handlePlay={handlePlay}
|
||||||
handlePause={handlePause}
|
handlePause={handlePause}
|
||||||
handleReset={handleReset}
|
handleReset={handleReset}
|
||||||
handleSeek={handleSeek}
|
handleSeek={handleSeek}
|
||||||
copyFFmpegCommand={copyFFmpegCommand}
|
copyFFmpegCommand={copyFFmpegCommand}
|
||||||
exportVideo={exportVideo}
|
exportVideo={exportVideo}
|
||||||
// Refs
|
|
||||||
layerRef={layerRef}
|
layerRef={layerRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ const VideoPreview = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>isPlaying: {isPlaying ? 'true' : 'false'}</div>
|
|
||||||
|
|
||||||
<Stage width={dimensions.width} height={dimensions.height} className="">
|
<Stage width={dimensions.width} height={dimensions.height} className="">
|
||||||
<Layer ref={layerRef}>
|
<Layer ref={layerRef}>
|
||||||
{activeElements.map((element) => {
|
{activeElements.map((element) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useMitt } from '@/plugins/MittContext';
|
import { useMitt } from '@/plugins/MittContext';
|
||||||
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
||||||
import { Download, Edit3, Pause, Play, Type } from 'lucide-react';
|
import { Download, Edit3, Play, Square, Type } from 'lucide-react';
|
||||||
|
|
||||||
const EditorControls = ({ className = '', onEditClick = () => {}, isEditActive = false }) => {
|
const EditorControls = ({ className = '', onEditClick = () => {}, isEditActive = false }) => {
|
||||||
const { videoIsPlaying } = useVideoEditorStore();
|
const { videoIsPlaying } = useVideoEditorStore();
|
||||||
@@ -14,13 +14,13 @@ const EditorControls = ({ className = '', onEditClick = () => {}, isEditActive =
|
|||||||
emitter.emit('video-play');
|
emitter.emit('video-play');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePause = () => {
|
const handleReset = () => {
|
||||||
emitter.emit('video-pause');
|
emitter.emit('video-reset');
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePlayPause = () => {
|
const togglePlayPause = () => {
|
||||||
if (videoIsPlaying) {
|
if (videoIsPlaying) {
|
||||||
handlePause();
|
handleReset();
|
||||||
} else {
|
} else {
|
||||||
handlePlay();
|
handlePlay();
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ const EditorControls = ({ className = '', onEditClick = () => {}, isEditActive =
|
|||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center justify-center gap-2', className)}>
|
<div className={cn('flex items-center justify-center gap-2', className)}>
|
||||||
<Button onClick={togglePlayPause} variant="ghost" size="icon" className="h-12 w-12 rounded-full border shadow-sm">
|
<Button onClick={togglePlayPause} variant="ghost" size="icon" className="h-12 w-12 rounded-full border shadow-sm">
|
||||||
{videoIsPlaying ? <Pause className="h-8 w-8" /> : <Play className="h-8 w-8" />}
|
{videoIsPlaying ? <Square className="h-8 w-8" /> : <Play className="h-8 w-8" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* <Button
|
{/* <Button
|
||||||
|
|||||||
Reference in New Issue
Block a user