"use client"; import { Check, ChevronDown, Download, Eye, EyeOff, Gauge, Link2, Loader2, PanelsTopLeft, Maximize2, Minus, Play, Plus, Redo2, Scissors, TextCursorInput, Trash2, Undo2, Video, X, ZoomIn, ZoomOut, } from "lucide-react"; import { type DragEvent, type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; import { createVideoCanvasCompositionClip, getVideoCanvasCompositionClipDuration, getVideoCanvasCompositionClipStart, getVideoCanvasCompositionDuration, splitVideoCanvasCompositionClip, type VideoCanvasStoryboardCompositionProject, type VideoCanvasStoryboardCompositionTransition, } from "@/lib/video-canvas/storyboard-composition"; import type { VideoCanvasStoryboardCompositionItem, VideoCanvasStoryboardItem, } from "@/lib/video-canvas/storyboard-overview"; type CompositionEditorProps = { composition: VideoCanvasStoryboardCompositionItem; compositionItems: VideoCanvasStoryboardCompositionItem[]; exporting: boolean; onChange: (project: VideoCanvasStoryboardCompositionProject) => void; onClose: () => void; onExport: (destination: "canvas" | "local") => void; onSwitchComposition: (compositionId: string) => void; project: VideoCanvasStoryboardCompositionProject; videoItems: VideoCanvasStoryboardItem[]; }; const TRANSITION_OPTIONS: Array<{ label: string; value: VideoCanvasStoryboardCompositionTransition; }> = [ { label: "淡入淡出", value: "fade" }, { label: "黑场过渡", value: "fade-black" }, { label: "白场过渡", value: "fade-white" }, ]; const SPEED_OPTIONS = [0.5, 1, 1.5, 2]; const formatTime = (seconds: number) => { const safeSeconds = Math.max(0, Math.floor(seconds)); const minutes = Math.floor(safeSeconds / 60); const remainder = safeSeconds % 60; return `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`; }; export function VideoCanvasStoryboardCompositionEditor({ composition, compositionItems, exporting, onChange, onClose, onExport, onSwitchComposition, project, videoItems, }: CompositionEditorProps) { const previewVideoRef = useRef(null); const compositionMenuRef = useRef(null); const [selectedClipId, setSelectedClipId] = useState(null); const [selectedSubtitleId, setSelectedSubtitleId] = useState(null); const [playheadSeconds, setPlayheadSeconds] = useState(0); const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); const [subtitlePanelOpen, setSubtitlePanelOpen] = useState(false); const [subtitleTab, setSubtitleTab] = useState<"subtitle" | "text">("subtitle"); const [transitionPanelOpen, setTransitionPanelOpen] = useState(false); const [speedMenuOpen, setSpeedMenuOpen] = useState(false); const [exportMenuOpen, setExportMenuOpen] = useState(false); const [compositionMenuOpen, setCompositionMenuOpen] = useState(false); const [previewCollapsed, setPreviewCollapsed] = useState(false); const [previewFullscreen, setPreviewFullscreen] = useState(false); const [snapEnabled, setSnapEnabled] = useState(true); const [subtitleTrackVisible, setSubtitleTrackVisible] = useState(true); const [timelineZoom, setTimelineZoom] = useState(20); const totalDuration = getVideoCanvasCompositionDuration(project); const timelineDuration = Math.max(totalDuration, 10); const selectedClip = project.clips.find((clip) => clip.id === selectedClipId) || null; const selectedSubtitle = project.subtitles.find((subtitle) => subtitle.id === selectedSubtitleId) || null; const previewClip = selectedClip || project.clips[0] || null; useEffect(() => { setSelectedClipId(null); setSelectedSubtitleId(null); setPlayheadSeconds(0); setUndoStack([]); setRedoStack([]); setSubtitlePanelOpen(false); setTransitionPanelOpen(false); setSpeedMenuOpen(false); setExportMenuOpen(false); setCompositionMenuOpen(false); setPreviewCollapsed(false); }, [composition.id]); useEffect(() => { if (selectedClipId && !selectedClip) setSelectedClipId(null); }, [selectedClip, selectedClipId]); useEffect(() => { if (selectedSubtitleId && !selectedSubtitle) setSelectedSubtitleId(null); }, [selectedSubtitle, selectedSubtitleId]); useEffect(() => { setPlayheadSeconds((current) => Math.min(current, totalDuration)); }, [totalDuration]); useEffect(() => { const closeOnEscape = (event: KeyboardEvent) => { if (event.key !== "Escape") return; if (previewFullscreen) { setPreviewFullscreen(false); return; } if (exportMenuOpen || speedMenuOpen) { setExportMenuOpen(false); setSpeedMenuOpen(false); } }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); }, [exportMenuOpen, previewFullscreen, speedMenuOpen]); useEffect(() => { if (!compositionMenuOpen) return; const closeOnOutsidePointer = (event: PointerEvent) => { if ( event.target instanceof Node && !compositionMenuRef.current?.contains(event.target) ) { setCompositionMenuOpen(false); } }; document.addEventListener("pointerdown", closeOnOutsidePointer, true); return () => document.removeEventListener("pointerdown", closeOnOutsidePointer, true); }, [compositionMenuOpen]); const applyProject = useCallback( (nextProject: VideoCanvasStoryboardCompositionProject) => { if (nextProject === project) return; setUndoStack((current) => [...current.slice(-49), structuredClone(project)]); setRedoStack([]); onChange(nextProject); }, [onChange, project], ); const undo = useCallback(() => { const previous = undoStack.at(-1); if (!previous) return; setUndoStack((current) => current.slice(0, -1)); setRedoStack((current) => [...current.slice(-49), structuredClone(project)]); onChange(previous); }, [onChange, project, undoStack]); const redo = useCallback(() => { const next = redoStack.at(-1); if (!next) return; setRedoStack((current) => current.slice(0, -1)); setUndoStack((current) => [...current.slice(-49), structuredClone(project)]); onChange(next); }, [onChange, project, redoStack]); const addVideoItem = useCallback( (item: VideoCanvasStoryboardItem) => { if (!item.mediaUrl) return; const clip = createVideoCanvasCompositionClip({ durationSeconds: item.durationSeconds || 5, existingIds: project.clips.map((candidate) => candidate.id), mediaUrl: item.mediaUrl, nodeId: item.id, title: item.title, }); applyProject({ ...project, clips: [...project.clips, clip] }); setSelectedClipId(clip.id); setPlayheadSeconds(totalDuration); }, [applyProject, project, totalDuration], ); const updateSelectedClip = useCallback( (update: (clip: NonNullable) => NonNullable) => { if (!selectedClip) return; applyProject({ ...project, clips: project.clips.map((clip) => clip.id === selectedClip.id ? update(clip) : clip, ), }); }, [applyProject, project, selectedClip], ); const removeSelectedClip = useCallback(() => { if (!selectedClip) return; applyProject({ ...project, clips: project.clips.filter((clip) => clip.id !== selectedClip.id), }); setSelectedClipId(null); }, [applyProject, project, selectedClip]); const trimSelectedClip = useCallback( (side: "left" | "right") => { if (!selectedClip) return; const clipStart = getVideoCanvasCompositionClipStart(project, selectedClip.id); if (clipStart === null) return; const sourcePoint = selectedClip.inPointSeconds + Math.max(0, playheadSeconds - clipStart) * selectedClip.speed; if (side === "left") { if (sourcePoint >= selectedClip.outPointSeconds - 0.1) return; updateSelectedClip((clip) => ({ ...clip, inPointSeconds: sourcePoint })); } else { if (sourcePoint <= selectedClip.inPointSeconds + 0.1) return; updateSelectedClip((clip) => ({ ...clip, outPointSeconds: sourcePoint })); } }, [playheadSeconds, project, selectedClip, updateSelectedClip], ); const splitSelectedClip = useCallback(() => { if (!selectedClip) return; const next = splitVideoCanvasCompositionClip( project, selectedClip.id, playheadSeconds, ); if (next !== project) applyProject(next); }, [applyProject, playheadSeconds, project, selectedClip]); const addSubtitle = useCallback(() => { const id = `subtitle-${Date.now()}`; const startSeconds = Math.min(playheadSeconds, totalDuration); const subtitle = { endSeconds: Math.min(Math.max(totalDuration, 2), startSeconds + 2), id, startSeconds, text: "新建字幕", }; applyProject({ ...project, subtitles: [...project.subtitles, subtitle] }); setSelectedSubtitleId(id); setSubtitleTab("text"); }, [applyProject, playheadSeconds, project, totalDuration]); const seekTimeline = (event: ReactPointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); if (!rect.width) return; let next = ((event.clientX - rect.left) / rect.width) * timelineDuration; if (snapEnabled) next = Math.round(next * 2) / 2; setPlayheadSeconds(Math.min(totalDuration, Math.max(0, next))); }; const handleDrop = (event: DragEvent) => { event.preventDefault(); const nodeId = event.dataTransfer.getData("application/x-video-canvas-node"); const item = videoItems.find((candidate) => candidate.id === nodeId); if (item) addVideoItem(item); }; const togglePlayback = async () => { const video = previewVideoRef.current; if (!video) return; if (video.paused) await video.play().catch(() => undefined); else video.pause(); }; const renderPreview = (fullscreen = false) => (
{previewClip ? (
); return (
视频
{videoItems.length ? (
{videoItems.map((item) => (
{ event.dataTransfer.effectAllowed = "copy"; event.dataTransfer.setData( "application/x-video-canvas-node", item.id, ); }} >
{item.title}
{item.mediaUrl ? (
{item.modelName || "视频素材"}
{item.durationSeconds ? (
{item.durationSeconds}秒
) : null}
{item.subtitle || "参考素材"}
))}
) : (
暂无视频素材
)}
{compositionMenuOpen ? (
{compositionItems.map((item) => ( ))}
) : null}
{!previewCollapsed ? ( <>
视频合成
{exportMenuOpen ? (
导出位置
) : null}
{transitionPanelOpen ? ( ) : null} {renderPreview()} {transitionPanelOpen ? ( ) : subtitlePanelOpen ? (