import React, { useEffect, useMemo, useState } from 'react'; import { Play, Pause } from 'lucide-react'; import { cn } from '../lib/utils'; import { useStore } from '../store/useStore'; export function FrameTimeline() { const frames = useStore((state) => state.frames); const currentProject = useStore((state) => state.currentProject); const currentFrameIndex = useStore((state) => state.currentFrameIndex); const masks = useStore((state) => state.masks); const setCurrentFrame = useStore((state) => state.setCurrentFrame); const [isPlaying, setIsPlaying] = useState(false); const totalFrames = frames.length; const currentFrame = totalFrames > 0 ? currentFrameIndex + 1 : 0; const playbackFps = useMemo(() => { const fps = currentProject?.parse_fps || currentProject?.original_fps || 12; return Math.min(Math.max(fps, 1), 30); }, [currentProject?.original_fps, currentProject?.parse_fps]); const timeBaseFps = useMemo(() => { const fps = currentProject?.parse_fps || currentProject?.original_fps || 12; return Math.max(fps, 1); }, [currentProject?.original_fps, currentProject?.parse_fps]); const currentSeconds = totalFrames > 0 ? currentFrameIndex / timeBaseFps : 0; const totalSeconds = totalFrames > 0 ? Math.max(totalFrames - 1, 0) / timeBaseFps : 0; const editedFrameMarkers = useMemo(() => { const frameIds = new Set(frames.map((frame) => frame.id)); const editedIds = new Set( masks .filter((mask) => frameIds.has(mask.frameId)) .map((mask) => mask.frameId), ); return frames .map((frame, index) => ({ frame, index })) .filter(({ frame }) => editedIds.has(frame.id)); }, [frames, masks]); const formatTime = (seconds: number) => { const safeSeconds = Math.max(0, seconds); const minutes = Math.floor(safeSeconds / 60); const wholeSeconds = Math.floor(safeSeconds % 60); const centiseconds = Math.floor((safeSeconds % 1) * 100); return `${minutes.toString().padStart(2, '0')}:${wholeSeconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`; }; useEffect(() => { if (!isPlaying || totalFrames <= 1) return; const timer = window.setTimeout(() => { if (currentFrameIndex >= totalFrames - 1) { setIsPlaying(false); return; } setCurrentFrame(currentFrameIndex + 1); }, 1000 / playbackFps); return () => window.clearTimeout(timer); }, [currentFrameIndex, isPlaying, playbackFps, setCurrentFrame, totalFrames]); useEffect(() => { if (totalFrames === 0) { setIsPlaying(false); } }, [totalFrames]); useEffect(() => { const isEditableTarget = (target: EventTarget | null) => { if (!(target instanceof HTMLElement)) return false; const tagName = target.tagName.toLowerCase(); return target.isContentEditable || ['input', 'textarea', 'select'].includes(tagName); }; const handleKeyDown = (event: KeyboardEvent) => { if (isEditableTarget(event.target) || totalFrames <= 1) return; if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); setIsPlaying(false); const direction = event.key === 'ArrowRight' ? 1 : -1; const nextIndex = Math.min(Math.max(currentFrameIndex + direction, 0), totalFrames - 1); if (nextIndex !== currentFrameIndex) { setCurrentFrame(nextIndex); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [currentFrameIndex, setCurrentFrame, totalFrames]); // show frames around current frame const frameWindow = 20; const displayIndices = totalFrames > 0 ? Array.from({ length: 41 }, (_, i) => currentFrameIndex - frameWindow + i) : []; return (
{formatTime(currentSeconds)}
{formatTime(totalSeconds)}
setCurrentFrame(parseInt(e.target.value) - 1)} className="w-full absolute inset-0 opacity-0 cursor-ew-resize z-20" disabled={totalFrames === 0} />
0 ? (currentFrame / totalFrames) * 100 : 0}%` }} /> {editedFrameMarkers.map(({ frame, index }) => { const left = totalFrames > 0 ? ((index + 1) / totalFrames) * 100 : 0; return (
已编辑 {editedFrameMarkers.length} 帧
{isPlaying ? '暂停 (SPACE)' : '播放序列 (F5)'}
{totalFrames === 0 ? (
暂无帧数据
) : ( displayIndices.map((idx) => { if (idx < 0 || idx >= totalFrames) { return
} const frame = frames[idx]; const isCurrent = idx === currentFrameIndex; return (
setCurrentFrame(idx)} className={cn( "relative shrink-0 rounded-sm transition-all cursor-pointer flex items-center justify-center overflow-hidden group mx-0.5", isCurrent ? "w-28 h-16 border-2 border-cyan-500 bg-gray-700 shadow-[0_0_15px_rgba(6,182,212,0.3)] z-10" : "w-16 h-12 border border-white/5 bg-gray-800/50 opacity-40 hover:opacity-100" )} > {frame.url ? ( {`frame-${idx}`} ) : (
)} {(idx + 1).toString().padStart(4, '0')}
); }) )}
{currentFrame} / {totalFrames}
{formatTime(currentSeconds)} / {formatTime(totalSeconds)}
底层时序视频图层截帧导航轴
); }