This commit is contained in:
ct
2025-06-15 18:32:05 +08:00
parent 54db08d78e
commit 85e7eb3546

View File

@@ -0,0 +1,574 @@
// TODO: I moved the sample timeline data to a dedicated file, and delayed the loading to 1 sec with useEffect. as such, alot of the ogics are broken. I need to make sure the delayed timeline should work like normal
/**
* ========================================
* VIDEO EDITOR SYSTEM ARCHITECTURE OVERVIEW
* ========================================
*
* This VideoEditor component is the core orchestrator of a multi-video timeline editing system.
* It manages synchronized playback of multiple video elements with precise timing control.
*
* SYSTEM FLOW:
*
* 1. INITIALIZATION PHASE:
* - Loads timeline data (delayed by 1s to simulate async loading)
* - Creates HTML video elements for each timeline video with multiple source formats
* - Loads poster images and calculates optimal scaling/positioning for canvas
* - Sets up cross-component event communication via MittContext
*
* 2. VIDEO SETUP PHASE:
* - For each video: creates <video> element with WebM + MOV sources for browser compatibility
* - Loads poster images to determine video dimensions and calculate centered positioning
* - Attaches event listeners for metadata loading and error handling
* - Updates timeline elements with positioning data and video element references
*
* 3. PLAYBACK CONTROL SYSTEM:
* - Animation loop runs at ~60fps using requestAnimationFrame
* - Updates throttled to ~20fps (0.05s intervals) for performance optimization
* - Calculates elapsed time from playback start + paused time offset
* - Synchronizes all video elements to match timeline position
* - Handles video trimming via inPoint offsets for precise start times
*
* 4. VIDEO SYNCHRONIZATION:
* - Determines which videos should be active at current timeline position
* - Plays/pauses individual videos based on their timeline segments
* - Manages audio muting to prevent overlapping audio tracks
* - Updates video currentTime with threshold-based seeking to avoid excessive operations
*
* 5. STATE MANAGEMENT:
* - Local state for timeline position, playback status, video elements
* - Global state sync via VideoEditorStore for cross-component coordination
* - Event-driven communication for play/pause/seek operations from UI controls
* - Loading progress tracking for user feedback during video initialization
*
* 6. PERFORMANCE OPTIMIZATIONS:
* - useCallback for stable function references to prevent unnecessary re-renders
* - Ref-based timing to avoid state dependency loops in animation
* - Throttled updates to balance smooth playback with performance
* - Cleanup functions to prevent memory leaks from video elements and event listeners
*
* 7. EXPORT INTEGRATION:
* - Integrates with useVideoExport hook for FFmpeg-based video rendering
* - Passes timeline data, dimensions, and duration for export processing
* - Provides export progress and status feedback to user interface
*
* KEY ARCHITECTURAL DECISIONS:
* - Separation of concerns: VideoEditor (logic) + VideoPreview (presentation)
* - Event-driven communication for loose coupling between components
* - HTML video elements for browser-native video handling and performance
* - Multiple source formats (WebM/MOV) for maximum browser compatibility
* - Poster-based sizing to ensure consistent layout before video metadata loads
* - Ref-based animation timing to avoid React state update performance issues
*
* TIMELINE ELEMENT STRUCTURE:
* Each timeline element contains: id, type, startTime, duration, inPoint, source files,
* positioning data (x, y, width, height), and runtime references (videoElement, posterImage)
*/
// Import dependencies for event communication, state management, React hooks, and child components
import { useMitt } from '@/plugins/MittContext'; // Event emitter for cross-component communication
import useVideoEditorStore from '@/stores/VideoEditorStore'; // Global state store for video editor
import { useCallback, useEffect, useRef, useState } from 'react'; // React hooks for state and lifecycle management
import sampleTimelineElements from './sample-timeline-data'; // Sample timeline data for testing/demo
import useVideoExport from './video-export'; // Custom hook for video export functionality
import VideoPreview from './video-preview'; // Child component that renders the actual video preview
/**
* VideoEditor - Main component that orchestrates video editing functionality
* Manages timeline elements, video playback, synchronization, and export
* @param {number} width - Canvas width for video rendering
* @param {number} height - Canvas height for video rendering
*/
const VideoEditor = ({ width, height }) => {
// Canvas dimensions - immutable once set, used for video scaling and positioning
const [dimensions] = useState({
width: width,
height: height,
});
// Timeline elements array - contains all video/audio/text elements with timing and positioning data
// Each element has: id, type, startTime, duration, source files, positioning, etc.
const [timelineElements, setTimelineElements] = useState([]);
// Performance optimization - tracks last animation frame update time to throttle rendering
const lastUpdateRef = useRef(0);
// Event emitter instance for cross-component communication (play, pause, seek events)
const emitter = useMitt();
// Current playback time in seconds - drives the entire timeline synchronization
const [currentTime, setCurrentTime] = useState(0);
// Playback state - controls animation loop and video element play/pause
const [isPlaying, setIsPlaying] = useState(false);
// Dictionary of HTML video elements keyed by timeline element ID
const [videoElements, setVideoElements] = useState({});
// Set tracking which video elements have finished loading (for UI feedback)
const [loadedVideos, setLoadedVideos] = useState(new Set());
// Status message for user feedback during loading/playback
const [status, setStatus] = useState('Loading videos...');
// Tracks which video elements should be playing at current time (optimization)
const [videoStates, setVideoStates] = useState({});
// Reference to the animation loop controller for cleanup
const animationRef = useRef(null);
// Reference to Konva layer for manual redraw triggering
const layerRef = useRef(null);
// Timestamp when playback started (for calculating elapsed time)
const startTimeRef = useRef(0);
// Time when playback was paused (for resuming from correct position)
const pausedTimeRef = useRef(0);
// Global state setter to sync playing state across the application
const { setVideoIsPlaying } = useVideoEditorStore();
// INITIALIZATION EFFECT: Load sample timeline data with 1 second delay
// This simulates async data loading and tests the component's ability to handle delayed timeline setup
useEffect(() => {
setTimeout(() => setTimelineElements(sampleTimelineElements), 1000);
}, []);
// GLOBAL STATE SYNC EFFECT: Keep global video playing state in sync with local state
// This allows other components to know when video is playing for UI updates
useEffect(() => {
setVideoIsPlaying(isPlaying);
}, [isPlaying, setVideoIsPlaying]);
// Calculate total timeline duration by finding the element that ends latest
// Used for progress bars, seek limits, and auto-stop functionality
const totalDuration = Math.max(...timelineElements.map((el) => el.startTime + el.duration));
// VIDEO EXPORT INTEGRATION: Custom hook that handles FFmpeg-based video export
// Provides export state, progress tracking, and export functions
const { isExporting, exportProgress, exportStatus, ffmpegCommand, copyFFmpegCommand, exportVideo } = useVideoExport({
timelineElements, // Timeline data for export processing
dimensions, // Canvas dimensions for output video
totalDuration, // Total duration for export progress calculation
});
// VIDEO ELEMENTS SETUP EFFECT: Creates and configures HTML video elements for each timeline video
// This is a complex effect that handles video loading, poster image scaling, and element positioning
useEffect(() => {
const videoEls = {}; // Local dictionary to store video elements before setting state
// Filter timeline to get only video elements (excludes text, audio, etc.)
const videoElementsData = timelineElements.filter((el) => el.type === 'video');
videoElementsData.forEach((element) => {
// Create HTML video element with optimal settings for timeline playback
const video = document.createElement('video');
video.crossOrigin = 'anonymous'; // Allow cross-origin video loading
video.muted = true; // Start muted to avoid autoplay restrictions
video.preload = 'metadata'; // Load metadata only to save bandwidth
video.playsInline = true; // Prevent fullscreen on mobile
video.controls = false; // Hide native video controls
// Add multiple video sources for browser compatibility
// WebM source with VP9 codec (modern browsers, better compression)
const sourceWebM = document.createElement('source');
sourceWebM.src = element.source_webm;
sourceWebM.type = 'video/webm; codecs=vp09.00.41.08';
// MOV source with HEVC codec (Safari, high quality)
const sourceMov = document.createElement('source');
sourceMov.src = element.source_mov;
sourceMov.type = 'video/quicktime; codecs=hvc1.1.6.H120.b0';
// Add sources to video element (browser will choose best supported format)
video.appendChild(sourceMov);
video.appendChild(sourceWebM);
// Load poster image for thumbnail display and size calculation
const posterImg = new Image();
posterImg.crossOrigin = 'anonymous'; // Allow cross-origin image loading
posterImg.src = element.poster;
// POSTER LOAD HANDLER: Calculate video positioning and scaling when poster loads
posterImg.onload = () => {
// Get canvas dimensions for scaling calculations
const maxWidth = dimensions.width;
const maxHeight = dimensions.height;
const posterWidth = posterImg.naturalWidth;
const posterHeight = posterImg.naturalHeight;
// Initialize with original dimensions
let scaledWidth = posterWidth;
let scaledHeight = posterHeight;
// Scale down if video is larger than canvas (maintain aspect ratio)
if (posterWidth > maxWidth || posterHeight > maxHeight) {
const scaleX = maxWidth / posterWidth; // Horizontal scale factor
const scaleY = maxHeight / posterHeight; // Vertical scale factor
const scale = Math.min(scaleX, scaleY); // Use smaller scale to fit both dimensions
scaledWidth = posterWidth * scale;
scaledHeight = posterHeight * scale;
}
// Center the video within the canvas
const centeredX = (maxWidth - scaledWidth) / 2;
const centeredY = (maxHeight - scaledHeight) / 2;
// Update timeline element with calculated positioning and poster data
setTimelineElements((prev) =>
prev.map((el) => {
if (el.id === element.id && el.type === 'video') {
return {
...el,
x: centeredX, // Horizontal position
y: centeredY, // Vertical position
width: scaledWidth, // Scaled width
height: scaledHeight, // Scaled height
posterImage: posterImg, // Poster image reference
isVideoPoster: true, // Flag indicating poster is loaded
};
}
return el;
}),
);
// Mark this video as loaded for progress tracking
setLoadedVideos((prev) => {
const newSet = new Set(prev);
newSet.add(element.id);
return newSet;
});
};
// VIDEO METADATA LOAD HANDLER: Attach video element when metadata is ready
video.addEventListener('loadedmetadata', () => {
setTimelineElements((prev) =>
prev.map((el) => {
if (el.id === element.id && el.type === 'video') {
return {
...el,
videoElement: video, // Attach HTML video element
isVideoReady: true, // Flag indicating video is ready for playback
};
}
return el;
}),
);
});
// ERROR HANDLERS: Log errors for debugging
video.addEventListener('error', (e) => {
console.error(`Error loading video ${element.id}:`, e);
});
posterImg.onerror = (e) => {
console.error(`Error loading poster ${element.id}:`, e);
};
// Store video element in local dictionary
videoEls[element.id] = video;
});
// Update state with all created video elements
setVideoElements(videoEls);
// CLEANUP FUNCTION: Properly dispose of video elements to prevent memory leaks
return () => {
Object.values(videoEls).forEach((video) => {
video.src = ''; // Clear source to stop loading
video.load(); // Reset video element
});
};
}, []); // Empty dependency array - only run once on mount
// LOADING STATUS EFFECT: Update status message based on video loading progress
// Provides user feedback during the video loading process
useEffect(() => {
const videoCount = timelineElements.filter((el) => el.type === 'video').length;
if (loadedVideos.size === videoCount && videoCount > 0) {
setStatus('Ready to play'); // All videos loaded
} else if (videoCount > 0) {
setStatus(`Loading videos... (${loadedVideos.size}/${videoCount})`); // Show progress
} else {
setStatus('Ready to play'); // No videos to load
}
}, [loadedVideos, timelineElements]);
// PAUSE HANDLER: Stops playback and pauses all video elements
// FIXED: Removed currentTime dependency to prevent excessive recreation
const handlePause = useCallback(() => {
if (isPlaying) {
setIsPlaying(false); // Update playback state
pausedTimeRef.current = currentTime; // Store current time for resume
// Pause all video elements and mute them
Object.values(videoElements).forEach((video) => {
if (!video.paused) {
video.pause(); // Pause video playback
}
video.muted = true; // Mute to prevent audio overlap
});
setVideoStates({}); // Clear video states
// Stop animation loop
if (animationRef.current) {
animationRef.current.stop();
animationRef.current = null;
}
}
}, [isPlaying, videoElements]);
// ACTIVE ELEMENTS CALCULATOR: Determines which timeline elements should be visible at given time
// Used for rendering optimization - only process elements that are currently active
const getActiveElements = useCallback(
(time) => {
return timelineElements.filter((element) => {
const elementEndTime = element.startTime + element.duration;
// Element is active if current time is within its start and end time
return time >= element.startTime && time < elementEndTime;
});
},
[timelineElements],
);
// VIDEO STATES CALCULATOR: Determines which videos should be playing at given time
// Returns object mapping video IDs to boolean play states for synchronization
const getDesiredVideoStates = useCallback(
(time) => {
const states = {};
timelineElements.forEach((element) => {
if (element.type === 'video') {
const elementEndTime = element.startTime + element.duration;
// Video should play if current time is within its timeline segment
states[element.id] = time >= element.startTime && time < elementEndTime;
}
});
return states;
},
[timelineElements],
);
// VIDEO TIME SYNCHRONIZER: Updates individual video currentTime to match timeline position
// Handles video trimming (inPoint) and prevents excessive seeking with threshold
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;
// Only update videos that are currently active on timeline
if (time >= element.startTime && time < elementEndTime) {
// Calculate relative time within the element's duration
const relativeTime = time - element.startTime;
// Add inPoint offset for trimmed videos (start from specific time in source)
const videoTime = element.inPoint + relativeTime;
// Only seek if difference is significant (>0.5s) to avoid excessive seeking
if (Math.abs(video.currentTime - videoTime) > 0.5) {
video.currentTime = videoTime;
}
}
}
});
},
[timelineElements, videoElements],
);
// VIDEO PLAYBACK SYNCHRONIZATION EFFECT: Manages play/pause state of individual videos
// Ensures only videos that should be active at current time are playing
useEffect(() => {
if (!isPlaying) return; // Skip if timeline is paused
// Get which videos should be playing at current time
const desiredStates = getDesiredVideoStates(currentTime);
// Synchronize each video's play state with desired state
Object.entries(desiredStates).forEach(([videoId, shouldPlay]) => {
const video = videoElements[videoId];
const isCurrentlyPlaying = !video?.paused;
if (video) {
if (shouldPlay && !isCurrentlyPlaying) {
// Start playing: unmute and play
video.muted = false;
video.play().catch((e) => console.warn('Video play failed:', e));
} else if (!shouldPlay && isCurrentlyPlaying) {
// Stop playing: pause and mute
video.pause();
video.muted = true;
}
}
});
// Update video states for UI feedback
setVideoStates(desiredStates);
}, [currentTime, isPlaying, videoElements, getDesiredVideoStates]);
// ANIMATION LOOP EFFECT: Main playback engine that drives timeline progression
// FIXED: Properly stop animation when not playing
useEffect(() => {
// Stop any existing animation when not playing
if (!isPlaying) {
if (animationRef.current) {
animationRef.current.stop();
animationRef.current = null;
}
return;
}
let animationId; // RequestAnimationFrame ID for cleanup
let isRunning = true; // Flag to control animation loop
// ANIMATION FRAME FUNCTION: Called ~60fps to update timeline
const animateFrame = () => {
if (!isRunning) return; // Exit if animation was stopped
// Calculate elapsed time since playback started
const now = Date.now() / 1000;
const newTime = pausedTimeRef.current + (now - startTimeRef.current);
// Auto-stop and reset when reaching end of timeline
if (newTime >= totalDuration) {
handlePause();
handleSeek(0);
return;
}
// Throttle updates to ~20fps (0.05s intervals) for performance
if (newTime - lastUpdateRef.current >= 0.05) {
lastUpdateRef.current = newTime;
setCurrentTime(newTime); // Update timeline position
updateVideoTimes(newTime); // Sync video elements
// Trigger Konva layer redraw for visual updates
if (layerRef.current) {
layerRef.current.batchDraw();
}
}
// Schedule next frame if still running
if (isRunning) {
animationId = requestAnimationFrame(animateFrame);
}
};
// Initialize animation timing and start loop
startTimeRef.current = Date.now() / 1000;
animationId = requestAnimationFrame(animateFrame);
// Create animation controller for external stop capability
animationRef.current = {
stop: () => {
isRunning = false;
if (animationId) {
cancelAnimationFrame(animationId);
}
},
};
// CLEANUP: Stop animation when effect unmounts or dependencies change
return () => {
isRunning = false;
if (animationId) {
cancelAnimationFrame(animationId);
}
};
}, [isPlaying, totalDuration, handlePause, updateVideoTimes]);
// PLAY HANDLER: Starts timeline playback from current position
// FIXED: Stabilized handlers to prevent unnecessary re-renders
const handlePlay = useCallback(() => {
if (!isPlaying) {
setIsPlaying(true); // Trigger animation loop
startTimeRef.current = Date.now() / 1000; // Record start time for elapsed calculation
lastUpdateRef.current = 0; // Reset update throttling
setStatus(''); // Clear status message during playback
}
}, [isPlaying]);
// SEEK HANDLER: Jumps to specific time position on timeline
const handleSeek = useCallback(
(time) => {
// Clamp time to valid range [0, totalDuration]
const clampedTime = Math.max(0, Math.min(time, totalDuration));
setCurrentTime(clampedTime); // Update timeline position
pausedTimeRef.current = clampedTime; // Update pause reference for resume
updateVideoTimes(clampedTime); // Sync all video elements to new time
setVideoStates({}); // Clear video states to force recalculation
// Trigger immediate visual update for seek feedback
if (layerRef.current) {
layerRef.current.draw();
}
},
[totalDuration, updateVideoTimes],
);
// RESET HANDLER: Stops playback and returns to beginning of timeline
const handleReset = useCallback(() => {
handlePause(); // Stop playback
handleSeek(0); // Jump to start of timeline
lastUpdateRef.current = 0; // Reset animation throttling
// Ensure all videos are muted after reset
Object.values(videoElements).forEach((video) => {
video.muted = true;
});
}, [handlePause, handleSeek, videoElements]);
// Get elements that should be visible/active at current timeline position
// Used by VideoPreview component for rendering decisions
const activeElements = getActiveElements(currentTime);
// EVENT LISTENERS EFFECT: Subscribe to cross-component video control events
// FIXED: Added missing dependencies to event listeners
useEffect(() => {
// Subscribe to events from other components (timeline controls, keyboard shortcuts, etc.)
emitter.on('video-play', handlePlay); // Play button clicked
emitter.on('video-reset', handleReset); // Reset button clicked
emitter.on('video-seek', handleSeek); // Timeline scrubber moved
// CLEANUP: Unsubscribe from events to prevent memory leaks
return () => {
emitter.off('video-play', handlePlay);
emitter.off('video-reset', handleReset);
emitter.off('video-seek', handleSeek);
};
}, [emitter, handlePlay, handleReset, handleSeek]);
// COMPONENT RENDER: Return the video editor UI wrapped in a container
return (
<div style={{ width: dimensions.width, height: dimensions.height }} className="rounded-3xl">
{/*
VideoPreview Component - Handles the actual video rendering and UI
Receives all state, handlers, and data needed for video preview functionality
This separation allows VideoEditor to focus on logic while VideoPreview handles presentation
*/}
<VideoPreview
// Canvas and timing props
dimensions={dimensions} // Canvas width/height for rendering
currentTime={currentTime} // Current playback position
totalDuration={totalDuration} // Total timeline duration
isPlaying={isPlaying} // Playback state
status={status} // Loading/status message
// Export-related props
isExporting={isExporting} // Export in progress flag
exportProgress={exportProgress} // Export progress percentage
exportStatus={exportStatus} // Export status message
ffmpegCommand={ffmpegCommand} // Generated FFmpeg command
copyFFmpegCommand={copyFFmpegCommand} // Function to copy command
exportVideo={exportVideo} // Function to start export
// Timeline and video data
timelineElements={timelineElements} // All timeline elements
activeElements={activeElements} // Currently visible elements
videoElements={videoElements} // HTML video element references
loadedVideos={loadedVideos} // Set of loaded video IDs
videoStates={videoStates} // Current video play states
// Control handlers
handlePlay={handlePlay} // Start playback
handlePause={handlePause} // Pause playback
handleReset={handleReset} // Reset to beginning
handleSeek={handleSeek} // Seek to specific time
// Rendering reference
layerRef={layerRef} // Konva layer reference for manual redraws
/>
</div>
);
};
export default VideoEditor;