/** * 水平轨道时间线 — 支持裁剪手柄、分割、右键菜单 * 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序 * "+" 卡片 → 类型+时长选择器 * * 裁剪交互: * - 鼠标悬停片段两端显示拖拽手柄,拖动调整入点/出点 * - 拖动时实时显示裁剪预览(入点/出点/时长) * - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除 */ import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react" import type { ClipData, ClipType, TrimConfig } from "../types" import { TRANSITION_OPTIONS } from "@/api/template-editor" interface TimelinePanelProps { clips: ClipData[] selectedClipId: string | null currentMode: string onClipSelect: (clipId: string) => void onClipReorder: (fromIdx: number, toIdx: number) => void onClipRemove: (clipId: string) => void onAddClip: (type: ClipType, duration: number) => void /** 裁剪更新:调整片段的 trim_config 和 duration */ onClipTrim?: (clipId: string, trimConfig: TrimConfig, newDuration: number) => void /** 在指定位置分割片段 */ onClipSplit?: (clipId: string, splitRatio: number) => void /** 恢复片段原始长度 */ onClipResetTrim?: (clipId: string) => void /** 当前播放时间(秒) */ currentTime?: number /** 缩放:每秒像素数 */ pixelsPerSecond?: number /** 缩放变更回调 */ onZoomChange?: (pps: number) => void /** 播放头跳转回调 */ onSeek?: (time: number) => void /** 总时长(秒),可选(默认由 clips 计算) */ totalDuration?: number } /** 片段类型图标 */ const CLIP_TYPE_ICONS: Record = { voice: "🎙️", pip: "🖼️", } /** 片段类型标签 */ const CLIP_TYPE_LABELS: Record = { voice: "口播", pip: "混剪", } /** 裁剪拖拽方向 */ type TrimDirection = "left" | "right" /** 裁剪拖拽状态 */ interface TrimDragState { clipId: string direction: TrimDirection startX: number originalTrim: TrimConfig originalDuration: number } /** 右键菜单状态 */ interface ContextMenuState { x: number y: number clipId: string } const TimelinePanel: React.FC = ({ clips, selectedClipId, currentMode, onClipSelect, onClipReorder, onClipRemove, onAddClip, onClipTrim, onClipSplit, onClipResetTrim, currentTime = 0, pixelsPerSecond = 40, onZoomChange, onSeek, totalDuration: totalDurationProp, }) => { const [dragIdx, setDragIdx] = useState(null) const [dragOverIdx, setDragOverIdx] = useState(null) const dragRef = useRef(null) const [showAddPicker, setShowAddPicker] = useState(false) const pickerRef = useRef(null) const addCardRef = useRef(null) const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0, }) /* ── 裁剪拖拽状态 ── */ const [trimDrag, setTrimDrag] = useState(null) const [trimPreview, setTrimPreview] = useState<{ clipId: string startTime: number endTime: number duration: number x: number y: number } | null>(null) /* ── 右键菜单 ── */ const [contextMenu, setContextMenu] = useState(null) const contextMenuRef = useRef(null) /* ── 悬停的片段 ID(显示裁剪手柄) ── */ const [hoveredClipId, setHoveredClipId] = useState(null) /* ── 播放头拖拽状态 ── */ const [playheadDragging, setPlayheadDragging] = useState(false) const trackRef = useRef(null) /* ── 根据模式决定可选类型 ── */ const availableTypes: ClipType[] = useMemo( () => currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"], // voice_pip / one_take / 默认 [currentMode], ) /* ── 默认添加类型:跟随模式 ── */ const defaultAddType: ClipType = useMemo(() => { if (currentMode === "voice_over") return "voice" if (currentMode === "pip") return "pip" return "voice" }, [currentMode]) /* ── "+" 卡片:类型+时长选择状态 ── */ const [addType, setAddType] = useState(defaultAddType) const [addDuration, setAddDuration] = useState(5) /* ── 模式切换时自动同步默认添加类型 ── */ useEffect(() => { if (!availableTypes.includes(addType)) { setAddType(defaultAddType) } }, [currentMode, addType, availableTypes, defaultAddType]) /* ── 面板尺寸 ── */ const PICKER_W = 240 const GAP = 6 /* ── 计算 picker 初始位置 ── */ const updatePickerPosition = useCallback(() => { if (!addCardRef.current) return const rect = addCardRef.current.getBoundingClientRect() const vw = window.innerWidth const roughHeight = 180 let top = rect.top - GAP - roughHeight if (top < 8) top = 8 let right = vw - rect.right if (rect.right - PICKER_W < 8) { right = vw - PICKER_W - 8 } setPickerPos({ top, right }) }, []) const handleTogglePicker = () => { if (!showAddPicker) { const defaultType = currentMode === "pip" ? "pip" : currentMode === "voice_over" ? "voice" : "voice" setAddType(defaultType) updatePickerPosition() } setShowAddPicker((v) => !v) } /* ── 渲染后精确边界校正 ── */ useLayoutEffect(() => { if (!showAddPicker || !pickerRef.current || !addCardRef.current) return const pickerEl = pickerRef.current const addRect = addCardRef.current.getBoundingClientRect() const pickerH = pickerEl.offsetHeight const vh = window.innerHeight const vw = window.innerWidth let top = addRect.top - GAP - pickerH if (top < 8) { top = addRect.bottom + GAP if (top + pickerH > vh - 8) { top = vh - 8 - pickerH if (top < 8) top = 8 } } let right = vw - addRect.right const pickerRect = pickerEl.getBoundingClientRect() if (pickerRect.left < 8) { right = vw - PICKER_W - 8 } setPickerPos({ top, right }) }, [showAddPicker]) /* ── 点击外部关闭添加面板 ── */ useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { setShowAddPicker(false) } } if (showAddPicker) { document.addEventListener("mousedown", handleClickOutside) } return () => document.removeEventListener("mousedown", handleClickOutside) }, [showAddPicker]) /* ── 点击外部关闭右键菜单 ── */ useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { setContextMenu(null) } } if (contextMenu) { document.addEventListener("mousedown", handleClickOutside) } return () => document.removeEventListener("mousedown", handleClickOutside) }, [contextMenu]) /* ── 缩放 & 时长 ── */ const pps = pixelsPerSecond ?? 40 const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0) /* ── 播放头拖拽全局 mousemove/mouseup ── */ useEffect(() => { if (!playheadDragging) return const handleMouseMove = (e: MouseEvent) => { const trackEl = trackRef.current if (!trackEl) return const rect = trackEl.getBoundingClientRect() const x = e.clientX - rect.left + trackEl.scrollLeft const time = Math.max(0, Math.min(x / pps, totalDuration)) onSeek?.(Math.round(time * 10) / 10) } const handleMouseUp = () => { setPlayheadDragging(false) } document.addEventListener("mousemove", handleMouseMove) document.addEventListener("mouseup", handleMouseUp) return () => { document.removeEventListener("mousemove", handleMouseMove) document.removeEventListener("mouseup", handleMouseUp) } }, [playheadDragging, pps, totalDuration, onSeek]) /* ── 标尺点击跳转播放头 ── */ const handleRulerClick = useCallback( (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect() const x = e.clientX - rect.left const time = Math.max(0, Math.min(x / pps, totalDuration)) onSeek?.(Math.round(time * 10) / 10) }, [pps, totalDuration, onSeek], ) /* ── 播放头拖拽开始 ── */ const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() setPlayheadDragging(true) }, []) /* ── 确认添加片段 ── */ const handleConfirmAdd = () => { onAddClip(addType, addDuration) setShowAddPicker(false) } /* ── 片段拖拽排序 ── */ const handleDragStart = (e: React.DragEvent, idx: number) => { // 如果正在裁剪拖拽,不允许排序拖拽 if (trimDrag) return dragRef.current = idx setDragIdx(idx) e.dataTransfer.setData("application/x-clip-drag", String(idx)) e.dataTransfer.effectAllowed = "move" } const handleDragOver = (e: React.DragEvent, idx: number) => { e.preventDefault() e.dataTransfer.dropEffect = "move" setDragOverIdx(idx) } const handleDragEnd = () => { dragRef.current = null setDragIdx(null) setDragOverIdx(null) } const handleDrop = (e: React.DragEvent, toIdx: number) => { e.preventDefault() setDragOverIdx(null) const fromStr = e.dataTransfer.getData("application/x-clip-drag") if (fromStr !== "") { const fromIdx = Number(fromStr) if (fromIdx !== toIdx) { onClipReorder(fromIdx, toIdx) } } } /* ── 空轨道区域不接受素材拖入 ── */ const handleEmptyDragOver = (e: React.DragEvent) => { e.preventDefault() } /* ── 裁剪手柄拖拽 ── */ const handleTrimHandleMouseDown = useCallback( (e: React.MouseEvent, clipId: string, direction: TrimDirection) => { e.preventDefault() e.stopPropagation() const clip = clips.find((c) => c.id === clipId) if (!clip) return const trim: TrimConfig = clip.trim_config ?? { start_time: 0, end_time: clip.duration, original_duration: clip.duration, } setTrimDrag({ clipId, direction, startX: e.clientX, originalTrim: { ...trim }, originalDuration: clip.duration, }) }, [clips], ) /* ── 裁剪拖拽全局 mousemove/mouseup ── */ useEffect(() => { if (!trimDrag) return const PX_PER_SECOND = pixelsPerSecond ?? 40 // 与缩放级别同步 const handleMouseMove = (e: MouseEvent) => { const dx = e.clientX - trimDrag.startX const dtSec = dx / PX_PER_SECOND const clip = clips.find((c) => c.id === trimDrag.clipId) if (!clip) return const origTrim = trimDrag.originalTrim const origDur = origTrim.original_duration ?? trimDrag.originalDuration let newStart = origTrim.start_time let newEnd = origTrim.end_time if (trimDrag.direction === "left") { // 左手柄:调整入点 newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - 1)) } else { // 右手柄:调整出点 newEnd = Math.max(origTrim.start_time + 1, Math.min(origTrim.end_time + dtSec, origDur)) } const newDuration = Math.round((newEnd - newStart) * 10) / 10 setTrimPreview({ clipId: trimDrag.clipId, startTime: Math.round(newStart * 10) / 10, endTime: Math.round(newEnd * 10) / 10, duration: newDuration, x: e.clientX, y: e.clientY, }) } const handleMouseUp = () => { if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) { const newTrim: TrimConfig = { start_time: trimPreview.startTime, end_time: trimPreview.endTime, original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration, } onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration) } setTrimDrag(null) setTrimPreview(null) } document.addEventListener("mousemove", handleMouseMove) document.addEventListener("mouseup", handleMouseUp) return () => { document.removeEventListener("mousemove", handleMouseMove) document.removeEventListener("mouseup", handleMouseUp) } }, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond]) /* ── 右键菜单 ── */ const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => { e.preventDefault() e.stopPropagation() setContextMenu({ x: e.clientX, y: e.clientY, clipId }) }, []) /* ── 右键菜单操作 ── */ const handleContextSplit = useCallback(() => { if (!contextMenu) return if (onClipSplit) { onClipSplit(contextMenu.clipId, 0.5) // 在中间分割 } setContextMenu(null) }, [contextMenu, onClipSplit]) const handleContextResetTrim = useCallback(() => { if (!contextMenu) return if (onClipResetTrim) { onClipResetTrim(contextMenu.clipId) } setContextMenu(null) }, [contextMenu, onClipResetTrim]) const handleContextDelete = useCallback(() => { if (!contextMenu) return onClipRemove(contextMenu.clipId) setContextMenu(null) }, [contextMenu, onClipRemove]) /* ── 时间标尺 ── */ const trackWidth = Math.max(totalDuration * pps, 300) const rulerMarks: number[] = [] const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15 for (let t = 0; t <= totalDuration + step; t += step) { rulerMarks.push(t) } const formatTime = (sec: number) => { const m = Math.floor(sec / 60) const s = Math.floor(sec % 60) return `${m}:${s.toString().padStart(2, "0")}` } /** 格式化裁剪时间(精确到0.1秒) */ const formatTrimTime = (sec: number) => { return `${sec.toFixed(1)}s` } return (
{/* 时间线头部 */}
🎬 时间线 总时长: {formatTime(totalDuration)}
{/* 缩放控件 */}
onZoomChange?.(Number(e.target.value))} /> {pps}px/s
{/* 一镜到底提示 */} {currentMode === "one_take" &&
🎥 一镜到底模式无片段
} {/* 时间标尺 */} {currentMode !== "one_take" && (
{rulerMarks.map((t) => ( {t}s ))}
)} {/* 水平片段轨道 */} {currentMode !== "one_take" && (
{/* 播放头 */} {currentMode !== "one_take" && totalDuration > 0 && (
)} {clips.length === 0 ? (
🎬
点击右侧 + 添加片段
) : ( clips.map((clip, idx) => { /* 转场指示器 */ const trans = clip.transition const showTransition = idx > 0 && trans && trans.type !== "none" const transOpt = showTransition ? TRANSITION_OPTIONS.find((o) => o.value === trans!.type) : undefined /* 速度徽章 */ const speed = clip.speed const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01 /* 裁剪状态 */ const hasTrim = !!clip.trim_config const isHovered = hoveredClipId === clip.id return ( {/* 转场指示器 */} {showTransition && transOpt && (
{transOpt.icon} {trans!.duration.toFixed(1)}s
)}
handleDragStart(e, idx)} onDragOver={(e) => handleDragOver(e, idx)} onDragEnd={handleDragEnd} onDrop={(e) => handleDrop(e, idx)} onClick={() => onClipSelect(clip.id)} onContextMenu={(e) => handleContextMenu(e, clip.id)} onMouseEnter={() => setHoveredClipId(clip.id)} onMouseLeave={() => setHoveredClipId(null)} > {/* 左裁剪手柄 */} {isHovered && onClipTrim && (
handleTrimHandleMouseDown(e, clip.id, "left")} title="拖动调整入点" >
)} {/* 类型图标 */}
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
{/* 片段信息 */}
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1} {clip.duration}s {hasTrim && ( ✂ )}
{/* 速度徽章 */} {showSpeed && {speed!.rate.toFixed(1)}x} {/* 裁剪徽章 */} {hasTrim && ( ✂ )} {/* 右裁剪手柄 */} {isHovered && onClipTrim && (
handleTrimHandleMouseDown(e, clip.id, "right")} title="拖动调整出点" >
)} {/* 删除按钮 */}
) }) )} {/* ── 轨道末尾 "+" 添加卡片 ── */}
+
)} {/* 裁剪预览 tooltip */} {trimPreview && (
入点 {formatTrimTime(trimPreview.startTime)}
出点 {formatTrimTime(trimPreview.endTime)}
时长 {formatTrimTime(trimPreview.duration)}
)} {/* 右键菜单 */} {contextMenu && (
✂️ 分割片段
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && (
↩️ 恢复原始长度
)}
🗑️ 删除片段
)} {/* 类型+时长选择面板 */} {showAddPicker && (
添加片段
{/* 类型选择 */}
类型: {availableTypes.map((t) => ( ))}
{/* 时长输入 */}
时长: setAddDuration(Math.max(1, Math.min(120, Number(e.target.value) || 1))) } /> 秒
{/* 确认按钮 */}
)}
) } export default TimelinePanel