/** * Компонент для отображения визуальной связи между linked клипами */ import { AnimatePresence, motion } from "motion/react" import { useMemo } from "react" import { cn } from "@/lib/utils" import { useTimeline } from "../hooks/use-timeline" import type { TimelineClip } from "../types/timeline" interface LinkedClipsConnectorProps { className?: string timelineWidth: number timelineHeight: number pixelsPerSecond: number trackHeight: number trackPositions: Record // trackId -> y position showConnections?: boolean animateConnections?: boolean } interface ClipPosition { clip: TimelineClip x: number y: number width: number height: number trackId: string } interface LinkedConnection { id: string clip1: ClipPosition clip2: ClipPosition type: "video-audio" | "audio-video" | "multi-camera" isActive: boolean isHovered: boolean } export function LinkedClipsConnector({ className, timelineWidth, timelineHeight, pixelsPerSecond, trackHeight, trackPositions, showConnections = true, animateConnections = true, }: LinkedClipsConnectorProps) { const { project } = useTimeline() const timeToPixel = (time: number) => time * pixelsPerSecond // Собираем все клипы и их позиции const clipPositions = useMemo((): ClipPosition[] => { if (!project) return [] const positions: ClipPosition[] = [] const processClips = (clips: TimelineClip[], trackId: string) => { clips.forEach((clip) => { const trackY = trackPositions[trackId] || 0 const position: ClipPosition = { clip, x: timeToPixel(clip.startTime), y: trackY, width: timeToPixel(clip.duration), height: trackHeight, trackId, } positions.push(position) }) } // Обрабатываем глобальные треки project.globalTracks?.forEach((track) => { processClips(track.clips, track.id) }) // Обрабатываем секции project.sections?.forEach((section) => { section.tracks?.forEach((track) => { processClips(track.clips, track.id) }) }) return positions }, [project, trackPositions, timeToPixel, trackHeight]) // Находим связанные клипы const linkedConnections = useMemo((): LinkedConnection[] => { const connections: LinkedConnection[] = [] clipPositions.forEach((position1) => { const { clip: clip1 } = position1 // Проверяем, есть ли связанный клип if (clip1.linkedClipId) { const position2 = clipPositions.find((pos) => pos.clip.id === clip1.linkedClipId) if (position2) { const { clip: clip2 } = position2 // Определяем тип связи // TODO: В новой архитектуре mediaFile не доступен, нужно определять тип по trackId или другим критериям const type: LinkedConnection["type"] = "video-audio" // Временное значение по умолчанию const connection: LinkedConnection = { id: `${clip1.id}-${clip2.id}`, clip1: position1, clip2: position2, type, isActive: clip1.isSelected || clip2.isSelected, isHovered: false, // Будет обновляться через состояние } connections.push(connection) } } }) return connections }, [clipPositions]) if (!showConnections || linkedConnections.length === 0) { return null } return (
{/* Градиенты для разных типов связей */} {/* Фильтры для свечения */} {linkedConnections.map((connection) => { const { clip1, clip2, type, isActive } = connection // Вычисляем точки соединения const clip1Center = { x: clip1.x + clip1.width / 2, y: clip1.y + clip1.height / 2, } const clip2Center = { x: clip2.x + clip2.width / 2, y: clip2.y + clip2.height / 2, } // Создаем кривую Безье для соединения const controlOffset = Math.abs(clip2Center.y - clip1Center.y) * 0.3 const controlPoint1 = { x: clip1Center.x + controlOffset, y: clip1Center.y, } const controlPoint2 = { x: clip2Center.x - controlOffset, y: clip2Center.y, } const pathData = `M ${clip1Center.x} ${clip1Center.y} C ${controlPoint1.x} ${controlPoint1.y}, ${controlPoint2.x} ${controlPoint2.y}, ${clip2Center.x} ${clip2Center.y}` const strokeColor = type === "video-audio" ? "url(#video-audio-gradient)" : type === "audio-video" ? "url(#audio-video-gradient)" : "url(#multi-camera-gradient)" return ( {/* Основная линия связи */} {/* Точки соединения */} {/* Центральная иконка типа связи */} {/* Иконка в зависимости от типа */} {type === "video-audio" && ( V→A )} {type === "audio-video" && ( A→V )} {type === "multi-camera" && ( MC )} ) })} {/* Подписи для активных соединений */} {linkedConnections .filter((connection) => connection.isActive) .map((connection) => { const { clip1, clip2, type } = connection const centerX = (clip1.x + clip1.width / 2 + clip2.x + clip2.width / 2) / 2 const centerY = (clip1.y + clip1.height / 2 + clip2.y + clip2.height / 2) / 2 const label = type === "video-audio" ? "Video → Audio Link" : type === "audio-video" ? "Audio → Video Link" : "Multi-Camera Link" return (
{label}
) })}
) } export default LinkedClipsConnector