634 lines
22 KiB
JavaScript
634 lines
22 KiB
JavaScript
import { useMitt } from '@/plugins/MittContext';
|
|
import useMediaStore from '@/stores/MediaStore';
|
|
import useUserStore from '@/stores/UserStore';
|
|
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import SINGLE_CAPTION_TEMPLATE from '../../templates/single_caption_meme_background.json';
|
|
import { generateTimelineFromTemplate } from '../../utils/timeline-template-processor';
|
|
import VideoDownloadModal from './video-download/video-download-modal';
|
|
import useVideoExport from './video-export';
|
|
import VideoPreview from './video-preview';
|
|
|
|
const VideoEditor = ({ width, height, onOpenTextSidebar }) => {
|
|
const { user_usage } = useUserStore();
|
|
|
|
const [isDownloadModalOpen, setIsDownloadModalOpen] = useState(false);
|
|
|
|
const [showConsoleLogs] = useState(true);
|
|
|
|
const [dimensions] = useState({
|
|
width: width,
|
|
height: height,
|
|
});
|
|
|
|
const [timelineElements, setTimelineElements] = useState([]);
|
|
const timelineElementsRef = useRef([]);
|
|
|
|
const lastUpdateRef = useRef(0);
|
|
const emitter = useMitt();
|
|
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [videoElements, setVideoElements] = useState({});
|
|
const [loadedVideos, setLoadedVideos] = useState(new Set());
|
|
const [status, setStatus] = useState('Loading videos...');
|
|
const [videoStates, setVideoStates] = useState({});
|
|
|
|
const animationRef = useRef(null);
|
|
const layerRef = useRef(null);
|
|
const startTimeRef = useRef(0);
|
|
const pausedTimeRef = useRef(0);
|
|
|
|
const { setVideoIsPlaying } = useVideoEditorStore();
|
|
const { selectedMeme, selectedBackground, currentCaption, watermarked } = useMediaStore();
|
|
|
|
const FPS_INTERVAL = 1000 / 30; // 30 FPS
|
|
|
|
// Keep ref synced with state
|
|
useEffect(() => {
|
|
timelineElementsRef.current = timelineElements;
|
|
}, [timelineElements]);
|
|
|
|
// Initialize timeline on mount
|
|
useEffect(() => {
|
|
initTimeline();
|
|
}, []);
|
|
|
|
// Watch MediaStore changes and regenerate timeline
|
|
useEffect(() => {
|
|
if (selectedMeme || selectedBackground) {
|
|
generateAndSetTimeline();
|
|
}
|
|
}, [selectedMeme, selectedBackground, currentCaption]);
|
|
|
|
const timelineUpdateResolverRef = useRef(null);
|
|
|
|
const setTimelineElementsAsync = useCallback((newElements) => {
|
|
return new Promise((resolve) => {
|
|
timelineUpdateResolverRef.current = resolve;
|
|
setTimelineElements(newElements);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (timelineUpdateResolverRef.current && timelineElements.length > 0) {
|
|
timelineUpdateResolverRef.current();
|
|
timelineUpdateResolverRef.current = null;
|
|
}
|
|
}, [timelineElements]);
|
|
|
|
const generateAndSetTimeline = () => {
|
|
const mediaStoreData = {
|
|
selectedMeme,
|
|
selectedBackground,
|
|
currentCaption: currentCaption || 'Default caption text',
|
|
};
|
|
|
|
const generatedTimeline = generateTimelineFromTemplate(dimensions, SINGLE_CAPTION_TEMPLATE, mediaStoreData);
|
|
|
|
if (generatedTimeline.length > 0) {
|
|
cleanupVideos(videoElements);
|
|
setTimelineElementsAsync(generatedTimeline).then(() => {
|
|
showConsoleLogs && console.log('Generated timeline from template:', generatedTimeline);
|
|
setupVideos();
|
|
setupImages();
|
|
});
|
|
}
|
|
};
|
|
|
|
const initTimeline = () => {
|
|
// Try to generate from current MediaStore state first
|
|
if (selectedMeme || selectedBackground) {
|
|
generateAndSetTimeline();
|
|
} else {
|
|
// Set empty timeline and wait for media selections
|
|
setTimelineElements([]);
|
|
setStatus('Select meme and background to start editing');
|
|
}
|
|
};
|
|
|
|
// Handle element transformations (position, scale, rotation) and text properties
|
|
const handleElementUpdate = useCallback(
|
|
(elementId, updates) => {
|
|
setTimelineElements((prev) =>
|
|
prev.map((element) => {
|
|
if (element.id === elementId) {
|
|
return {
|
|
...element,
|
|
...updates,
|
|
};
|
|
}
|
|
return element;
|
|
}),
|
|
);
|
|
|
|
// Force redraw if not playing
|
|
if (!isPlaying && layerRef.current) {
|
|
layerRef.current.batchDraw();
|
|
}
|
|
},
|
|
[isPlaying],
|
|
);
|
|
|
|
// Setup function for image elements
|
|
const setupImages = () => {
|
|
showConsoleLogs && console.log('setupImages');
|
|
|
|
const elements = timelineElementsRef.current;
|
|
|
|
if (elements.length === 0) {
|
|
console.log('No timeline elements to setup images for');
|
|
return;
|
|
}
|
|
|
|
const imageElementsData = elements.filter((el) => el.type === 'image');
|
|
console.log('Found', imageElementsData.length, 'image elements');
|
|
|
|
imageElementsData.forEach((element) => {
|
|
console.log('Creating image element for:', element.id);
|
|
|
|
const img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
img.src = element.source;
|
|
|
|
img.onload = () => {
|
|
console.log('Image loaded for:', element.id);
|
|
|
|
const maxWidth = dimensions.width;
|
|
const maxHeight = dimensions.height;
|
|
const imgWidth = img.naturalWidth;
|
|
const imgHeight = img.naturalHeight;
|
|
|
|
let scaledWidth = imgWidth;
|
|
let scaledHeight = imgHeight;
|
|
|
|
if (imgWidth > maxWidth || imgHeight > maxHeight) {
|
|
const scaleX = maxWidth / imgWidth;
|
|
const scaleY = maxHeight / imgHeight;
|
|
const scale = Math.min(scaleX, scaleY);
|
|
|
|
scaledWidth = imgWidth * scale;
|
|
scaledHeight = imgHeight * scale;
|
|
}
|
|
|
|
const centeredX = element.x !== undefined ? element.x : (maxWidth - scaledWidth) / 2;
|
|
const centeredY = element.y !== undefined ? element.y : (maxHeight - scaledHeight) / 2;
|
|
|
|
setTimelineElements((prev) =>
|
|
prev.map((el) => {
|
|
if (el.id === element.id && el.type === 'image') {
|
|
return {
|
|
...el,
|
|
x: centeredX,
|
|
y: centeredY,
|
|
width: element.width || scaledWidth,
|
|
height: element.height || scaledHeight,
|
|
rotation: element.rotation || 0,
|
|
imageElement: img,
|
|
isImageReady: true,
|
|
};
|
|
}
|
|
return el;
|
|
}),
|
|
);
|
|
|
|
setLoadedVideos((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.add(element.id);
|
|
console.log('Image loaded:', element.id, 'Total loaded:', newSet.size);
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
img.onerror = (e) => {
|
|
console.error(`Error loading image ${element.id}:`, e);
|
|
};
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
setupVideoStatus();
|
|
}, [timelineElements, loadedVideos]);
|
|
|
|
useEffect(() => {
|
|
setVideoIsPlaying(isPlaying);
|
|
}, [isPlaying, setVideoIsPlaying]);
|
|
|
|
const totalDuration = Math.max(...timelineElements.map((el) => el.startTime + el.duration));
|
|
|
|
const { isExporting, exportProgress, exportStatus, ffmpegCommand, copyFFmpegCommand, exportVideo, videoBlob, videoBlobFilename } = useVideoExport({
|
|
timelineElements,
|
|
dimensions,
|
|
totalDuration,
|
|
watermarked,
|
|
});
|
|
|
|
const setupVideos = () => {
|
|
showConsoleLogs && console.log('setupVideos');
|
|
|
|
const elements = timelineElementsRef.current;
|
|
|
|
if (elements.length === 0) {
|
|
console.log('No timeline elements to setup videos for');
|
|
return;
|
|
}
|
|
|
|
console.log('Setting up videos for', elements.length, 'timeline elements');
|
|
|
|
const videoEls = {};
|
|
const videoElementsData = elements.filter((el) => el.type === 'video');
|
|
|
|
console.log('Found', videoElementsData.length, 'video elements');
|
|
|
|
videoElementsData.forEach((element) => {
|
|
console.log('Creating video element for:', element.id);
|
|
|
|
const video = document.createElement('video');
|
|
video.crossOrigin = 'anonymous';
|
|
video.muted = true;
|
|
video.preload = 'metadata';
|
|
video.playsInline = true;
|
|
video.controls = false;
|
|
|
|
const sourceWebM = document.createElement('source');
|
|
sourceWebM.src = element.source_webm;
|
|
sourceWebM.type = 'video/webm; codecs=vp09.00.41.08';
|
|
|
|
const sourceMov = document.createElement('source');
|
|
sourceMov.src = element.source_mov;
|
|
sourceMov.type = 'video/quicktime; codecs=hvc1.1.6.H120.b0';
|
|
|
|
video.appendChild(sourceMov);
|
|
video.appendChild(sourceWebM);
|
|
|
|
const posterImg = new Image();
|
|
posterImg.crossOrigin = 'anonymous';
|
|
posterImg.src = element.poster;
|
|
|
|
posterImg.onload = () => {
|
|
console.log('Poster loaded for:', element.id);
|
|
|
|
const maxWidth = dimensions.width;
|
|
const maxHeight = dimensions.height;
|
|
const posterWidth = posterImg.naturalWidth;
|
|
const posterHeight = posterImg.naturalHeight;
|
|
|
|
let scaledWidth = posterWidth;
|
|
let scaledHeight = posterHeight;
|
|
|
|
if (posterWidth > maxWidth || posterHeight > maxHeight) {
|
|
const scaleX = maxWidth / posterWidth;
|
|
const scaleY = maxHeight / posterHeight;
|
|
const scale = Math.min(scaleX, scaleY);
|
|
|
|
scaledWidth = posterWidth * scale;
|
|
scaledHeight = posterHeight * scale;
|
|
}
|
|
|
|
const centeredX = element.x !== undefined ? element.x : (maxWidth - scaledWidth) / 2;
|
|
const centeredY = element.y !== undefined ? element.y : (maxHeight - scaledHeight) / 2;
|
|
|
|
setTimelineElements((prev) =>
|
|
prev.map((el) => {
|
|
if (el.id === element.id && el.type === 'video') {
|
|
return {
|
|
...el,
|
|
x: centeredX,
|
|
y: centeredY,
|
|
width: scaledWidth,
|
|
height: scaledHeight,
|
|
rotation: element.rotation || 0,
|
|
posterImage: posterImg,
|
|
isVideoPoster: true,
|
|
};
|
|
}
|
|
return el;
|
|
}),
|
|
);
|
|
|
|
setLoadedVideos((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.add(element.id);
|
|
console.log('Video loaded:', element.id, 'Total loaded:', newSet.size);
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
video.addEventListener('loadedmetadata', () => {
|
|
console.log('Video metadata loaded for:', element.id);
|
|
|
|
setTimelineElements((prev) =>
|
|
prev.map((el) => {
|
|
if (el.id === element.id && el.type === 'video') {
|
|
return {
|
|
...el,
|
|
videoElement: video,
|
|
isVideoReady: true,
|
|
};
|
|
}
|
|
return el;
|
|
}),
|
|
);
|
|
});
|
|
|
|
video.addEventListener('error', (e) => {
|
|
console.error(`Error loading video ${element.id}:`, e);
|
|
});
|
|
|
|
posterImg.onerror = (e) => {
|
|
console.error(`Error loading poster ${element.id}:`, e);
|
|
};
|
|
|
|
videoEls[element.id] = video;
|
|
});
|
|
|
|
console.log('Setting video elements:', Object.keys(videoEls));
|
|
setVideoElements(videoEls);
|
|
};
|
|
|
|
const cleanupVideos = (videosToCleanup) => {
|
|
if (!videosToCleanup) return;
|
|
|
|
const videoArray = Array.isArray(videosToCleanup) ? videosToCleanup : Object.values(videosToCleanup);
|
|
|
|
videoArray.forEach((video) => {
|
|
if (video && video.src) {
|
|
if (!video.paused) video.pause();
|
|
video.src = '';
|
|
video.load();
|
|
video.removeEventListener('loadedmetadata', video._metadataHandler);
|
|
video.removeEventListener('error', video._errorHandler);
|
|
}
|
|
});
|
|
};
|
|
|
|
const setupVideoStatus = () => {
|
|
const mediaCount = timelineElements.filter((el) => el.type === 'video' || el.type === 'image').length;
|
|
if (loadedVideos.size === mediaCount && mediaCount > 0) {
|
|
setStatus('Ready to play');
|
|
} else if (mediaCount > 0) {
|
|
setStatus(`Loading media... (${loadedVideos.size}/${mediaCount})`);
|
|
} else {
|
|
setStatus('Select meme and background to start editing');
|
|
}
|
|
};
|
|
|
|
// Rest of the component remains the same...
|
|
const handlePause = useCallback(() => {
|
|
if (isPlaying) {
|
|
setIsPlaying(false);
|
|
pausedTimeRef.current = currentTime;
|
|
|
|
Object.values(videoElements).forEach((video) => {
|
|
if (!video.paused) {
|
|
video.pause();
|
|
}
|
|
video.muted = true;
|
|
});
|
|
|
|
setVideoStates({});
|
|
|
|
if (animationRef.current) {
|
|
animationRef.current.stop();
|
|
animationRef.current = null;
|
|
}
|
|
}
|
|
}, [isPlaying, videoElements]);
|
|
|
|
const getActiveElements = useCallback(
|
|
(time) => {
|
|
return timelineElements.filter((element) => {
|
|
const elementEndTime = element.startTime + element.duration;
|
|
return time >= element.startTime && time < elementEndTime;
|
|
});
|
|
},
|
|
[timelineElements],
|
|
);
|
|
|
|
const getDesiredVideoStates = useCallback(
|
|
(time) => {
|
|
const states = {};
|
|
timelineElements.forEach((element) => {
|
|
if (element.type === 'video') {
|
|
const elementEndTime = element.startTime + element.duration;
|
|
states[element.id] = time >= element.startTime && time < elementEndTime;
|
|
}
|
|
});
|
|
return states;
|
|
},
|
|
[timelineElements],
|
|
);
|
|
|
|
const updateVideoTimes = useCallback(
|
|
(time) => {
|
|
timelineElements.forEach((element) => {
|
|
if (element.type === 'video' && videoElements[element.id]) {
|
|
const video = videoElements[element.id];
|
|
const elementEndTime = element.startTime + element.duration;
|
|
|
|
if (time >= element.startTime && time < elementEndTime) {
|
|
const relativeTime = time - element.startTime;
|
|
const videoTime = element.inPoint + relativeTime;
|
|
|
|
if (Math.abs(video.currentTime - videoTime) > 0.5) {
|
|
video.currentTime = videoTime;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
},
|
|
[timelineElements, videoElements],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isPlaying) return;
|
|
|
|
const desiredStates = getDesiredVideoStates(currentTime);
|
|
|
|
Object.entries(desiredStates).forEach(([videoId, shouldPlay]) => {
|
|
const video = videoElements[videoId];
|
|
const isCurrentlyPlaying = !video?.paused;
|
|
|
|
if (video) {
|
|
if (shouldPlay && !isCurrentlyPlaying) {
|
|
video.muted = false;
|
|
video.play().catch((e) => console.warn('Video play failed:', e));
|
|
} else if (!shouldPlay && isCurrentlyPlaying) {
|
|
video.pause();
|
|
video.muted = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
setVideoStates(desiredStates);
|
|
}, [currentTime, isPlaying, videoElements, getDesiredVideoStates]);
|
|
|
|
useEffect(() => {
|
|
if (!isPlaying) {
|
|
if (animationRef.current) {
|
|
animationRef.current.stop();
|
|
animationRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
let intervalId;
|
|
let isRunning = true;
|
|
|
|
const animateFrame = () => {
|
|
if (!isRunning) return;
|
|
|
|
const now = Date.now() / 1000;
|
|
const newTime = pausedTimeRef.current + (now - startTimeRef.current);
|
|
|
|
if (newTime >= totalDuration) {
|
|
handlePause();
|
|
handleSeek(0);
|
|
return;
|
|
}
|
|
|
|
lastUpdateRef.current = newTime;
|
|
setCurrentTime(newTime);
|
|
updateVideoTimes(newTime);
|
|
|
|
if (layerRef.current) {
|
|
layerRef.current.batchDraw();
|
|
}
|
|
};
|
|
|
|
startTimeRef.current = Date.now() / 1000;
|
|
intervalId = setInterval(animateFrame, FPS_INTERVAL);
|
|
|
|
animationRef.current = {
|
|
stop: () => {
|
|
isRunning = false;
|
|
if (intervalId) {
|
|
clearInterval(intervalId);
|
|
}
|
|
},
|
|
};
|
|
|
|
return () => {
|
|
isRunning = false;
|
|
if (intervalId) {
|
|
clearInterval(intervalId);
|
|
}
|
|
};
|
|
}, [isPlaying, totalDuration, handlePause, updateVideoTimes]);
|
|
|
|
const handlePlay = useCallback(() => {
|
|
if (!isPlaying) {
|
|
setIsPlaying(true);
|
|
startTimeRef.current = Date.now() / 1000;
|
|
lastUpdateRef.current = 0;
|
|
setStatus('');
|
|
}
|
|
}, [isPlaying]);
|
|
|
|
const handleSeek = useCallback(
|
|
(time) => {
|
|
const clampedTime = Math.max(0, Math.min(time, totalDuration));
|
|
setCurrentTime(clampedTime);
|
|
pausedTimeRef.current = clampedTime;
|
|
updateVideoTimes(clampedTime);
|
|
|
|
setVideoStates({});
|
|
|
|
if (layerRef.current) {
|
|
layerRef.current.draw();
|
|
}
|
|
},
|
|
[totalDuration, updateVideoTimes],
|
|
);
|
|
|
|
const handleReset = useCallback(() => {
|
|
handlePause();
|
|
handleSeek(0);
|
|
lastUpdateRef.current = 0;
|
|
|
|
Object.values(videoElements).forEach((video) => {
|
|
video.muted = true;
|
|
});
|
|
}, [handlePause, handleSeek, videoElements]);
|
|
|
|
const handleExport = useCallback(() => {
|
|
exportVideo();
|
|
}, [exportVideo]);
|
|
|
|
const handleOpenDownloadModal = () => {
|
|
setIsDownloadModalOpen(true);
|
|
};
|
|
|
|
const activeElements = getActiveElements(currentTime);
|
|
|
|
useEffect(() => {
|
|
emitter.on('video-open-download-modal', handleOpenDownloadModal);
|
|
emitter.on('video-export', handleExport);
|
|
emitter.on('video-play', handlePlay);
|
|
emitter.on('video-reset', handleReset);
|
|
emitter.on('video-seek', handleSeek);
|
|
emitter.on('text-update', ({ elementId, updates }) => {
|
|
handleElementUpdate(elementId, updates);
|
|
});
|
|
|
|
return () => {
|
|
emitter.off('video-open-download-modal', handleOpenDownloadModal);
|
|
emitter.off('video-export', handleExport);
|
|
emitter.off('video-play', handlePlay);
|
|
emitter.off('video-reset', handleReset);
|
|
emitter.off('video-seek', handleSeek);
|
|
emitter.off('text-update');
|
|
};
|
|
}, [emitter, handlePlay, handleReset, handleSeek, handleElementUpdate]);
|
|
|
|
return (
|
|
<>
|
|
<div style={{ width: dimensions.width, height: dimensions.height }} className="rounded-3xl">
|
|
<VideoPreview
|
|
watermarked={watermarked}
|
|
dimensions={dimensions}
|
|
currentTime={currentTime}
|
|
totalDuration={totalDuration}
|
|
isPlaying={isPlaying}
|
|
status={status}
|
|
isExporting={isExporting}
|
|
exportProgress={exportProgress}
|
|
exportStatus={exportStatus}
|
|
timelineElements={timelineElements}
|
|
activeElements={activeElements}
|
|
videoElements={videoElements}
|
|
loadedVideos={loadedVideos}
|
|
videoStates={videoStates}
|
|
ffmpegCommand={ffmpegCommand}
|
|
handlePlay={handlePlay}
|
|
handlePause={handlePause}
|
|
handleReset={handleReset}
|
|
handleSeek={handleSeek}
|
|
copyFFmpegCommand={copyFFmpegCommand}
|
|
exportVideo={exportVideo}
|
|
onElementUpdate={handleElementUpdate}
|
|
onOpenTextSidebar={onOpenTextSidebar}
|
|
layerRef={layerRef}
|
|
/>
|
|
</div>
|
|
<VideoDownloadModal
|
|
nonWatermarkVideosLeft={user_usage?.non_watermark_videos_left}
|
|
isOpen={isDownloadModalOpen}
|
|
onClose={() => setIsDownloadModalOpen(false)}
|
|
ffmpegCommand={ffmpegCommand}
|
|
handleDownloadButton={handleExport}
|
|
isExporting={isExporting}
|
|
exportProgress={exportProgress}
|
|
exportStatus={exportStatus}
|
|
videoBlob={videoBlob}
|
|
videoBlobFilename={videoBlobFilename}
|
|
selectedMeme={selectedMeme}
|
|
selectedBackground={selectedBackground}
|
|
currentCaption={currentCaption}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default VideoEditor;
|