1b7a7bcb55
CI/CD Pipeline / Validate - Code Quality (push) Failing after 0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Failing after 0s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Unit Tests (push) Failing after 0s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 0s
CI/CD Pipeline / Integration Tests (push) Failing after 0s
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1219h27m54s
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
/**
|
||
* 水平轨道时间线 — 支持裁剪手柄、分割、右键菜单
|
||
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
|
||
* "+" 卡片 → 类型+时长选择器
|
||
*
|
||
* 裁剪交互:
|
||
* - 鼠标悬停片段两端显示拖拽手柄,拖动调整入点/出点
|
||
* - 拖动时实时显示裁剪预览(入点/出点/时长)
|
||
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
|
||
*/
|
||
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||
import type { ClipData, ClipType, TrimConfig } from "../types"
|
||
import { DEFAULT_PIXELS_PER_SECOND } from "../constants/timeline"
|
||
import { formatTime } from "../utils/timeline"
|
||
import { useClipDrag } from "../hooks/useClipDrag"
|
||
import { useTrimDrag } from "../hooks/useTrimDrag"
|
||
import { useTimelineMenus } from "../hooks/useTimelineMenus"
|
||
import { ClipCard } from "./timeline/ClipCard"
|
||
import { TimeRuler } from "./timeline/TimeRuler"
|
||
import { AddClipPicker } from "./timeline/AddClipPicker"
|
||
import { TrimPreview } from "./timeline/TrimPreview"
|
||
import { ContextMenu } from "./timeline/ContextMenu"
|
||
|
||
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 TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||
clips,
|
||
selectedClipId,
|
||
currentMode,
|
||
onClipSelect,
|
||
onClipReorder,
|
||
onClipRemove,
|
||
onAddClip,
|
||
onClipTrim,
|
||
onClipSplit,
|
||
onClipResetTrim,
|
||
currentTime = 0,
|
||
pixelsPerSecond = 40,
|
||
onZoomChange,
|
||
onSeek,
|
||
totalDuration: totalDurationProp,
|
||
}) => {
|
||
/* ── 缩放 & 时长 ── */
|
||
const pps = pixelsPerSecond ?? DEFAULT_PIXELS_PER_SECOND
|
||
const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0)
|
||
|
||
/* ── 裁剪拖拽 ── */
|
||
const { trimDrag, trimPreview, handleTrimHandleMouseDown } = useTrimDrag(clips, pps, onClipTrim)
|
||
|
||
/* ── 片段拖拽排序 ── */
|
||
const {
|
||
dragIdx,
|
||
dragOverIdx,
|
||
handleDragStart,
|
||
handleDragOver,
|
||
handleDragEnd,
|
||
handleDrop,
|
||
handleEmptyDragOver,
|
||
} = useClipDrag(onClipReorder, !!trimDrag)
|
||
|
||
/* ── 菜单 & 面板 ── */
|
||
const {
|
||
contextMenu,
|
||
contextMenuRef,
|
||
handleContextMenu,
|
||
handleContextSplit,
|
||
handleContextResetTrim,
|
||
handleContextDelete,
|
||
showAddPicker,
|
||
pickerRef,
|
||
addCardRef,
|
||
pickerPos,
|
||
availableTypes,
|
||
addType,
|
||
addDuration,
|
||
setAddType,
|
||
setAddDuration,
|
||
handleTogglePicker,
|
||
handleConfirmAdd,
|
||
hoveredClipId,
|
||
setHoveredClipId,
|
||
} = useTimelineMenus(clips, currentMode, onAddClip, onClipSplit, onClipResetTrim, onClipRemove)
|
||
|
||
/* ── 播放头拖拽状态 ── */
|
||
const [playheadDragging, setPlayheadDragging] = useState(false)
|
||
const trackRef = useRef<HTMLDivElement>(null)
|
||
|
||
/* ── 播放头拖拽全局 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<HTMLDivElement>) => {
|
||
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)
|
||
}, [])
|
||
|
||
return (
|
||
<div className="ep-timeline-area">
|
||
{/* 时间线头部 */}
|
||
<div className="ep-timeline-header">
|
||
<div className="ep-timeline-title">
|
||
<span>🎬 时间线</span>
|
||
<span className="ep-timeline-duration">总时长: {formatTime(totalDuration)}</span>
|
||
</div>
|
||
<div className="ep-timeline-actions">
|
||
{/* 缩放控件 */}
|
||
<div className="ep-timeline-zoom">
|
||
<button
|
||
className="ep-zoom-btn"
|
||
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
|
||
title="缩小"
|
||
>
|
||
−
|
||
</button>
|
||
<input
|
||
type="range"
|
||
className="ep-zoom-slider"
|
||
min={10}
|
||
max={120}
|
||
step={5}
|
||
value={pps}
|
||
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
||
/>
|
||
<button
|
||
className="ep-zoom-btn"
|
||
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
|
||
title="放大"
|
||
>
|
||
+
|
||
</button>
|
||
<span className="ep-zoom-label">{pps}px/s</span>
|
||
</div>
|
||
<button
|
||
className="ep-timeline-action-btn"
|
||
onClick={() => {
|
||
if (clips.length > 1) {
|
||
const last = clips[clips.length - 1]
|
||
onClipRemove(last.id)
|
||
}
|
||
}}
|
||
title="删除最后一个片段"
|
||
>
|
||
↩
|
||
</button>
|
||
<button className="ep-timeline-action-btn" title="重做">
|
||
↪
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 一镜到底提示 */}
|
||
{currentMode === "one_take" && <div className="ep-one-take-hint">🎥 一镜到底模式无片段</div>}
|
||
|
||
{/* 时间标尺 */}
|
||
{currentMode !== "one_take" && (
|
||
<TimeRuler totalDuration={totalDuration} pps={pps} onClick={handleRulerClick} />
|
||
)}
|
||
|
||
{/* 水平片段轨道 */}
|
||
{currentMode !== "one_take" && (
|
||
<div className="ep-clip-track" ref={trackRef} onDragOver={handleEmptyDragOver}>
|
||
{/* 播放头 */}
|
||
{currentMode !== "one_take" && totalDuration > 0 && (
|
||
<div
|
||
className="ep-playhead"
|
||
style={{ left: currentTime * pps }}
|
||
onMouseDown={handlePlayheadMouseDown}
|
||
>
|
||
<div className="ep-playhead-handle" />
|
||
</div>
|
||
)}
|
||
{clips.length === 0 ? (
|
||
<div className="ep-track-empty">
|
||
<div className="ep-track-empty-icon">🎬</div>
|
||
<div className="ep-track-empty-text">点击右侧 + 添加片段</div>
|
||
</div>
|
||
) : (
|
||
clips.map((clip, idx) => (
|
||
<ClipCard
|
||
key={clip.id}
|
||
clip={clip}
|
||
idx={idx}
|
||
isSelected={selectedClipId === clip.id}
|
||
isDragging={dragIdx === idx}
|
||
isDragOver={dragOverIdx === idx}
|
||
isHovered={hoveredClipId === clip.id}
|
||
pps={pps}
|
||
trimDragActive={!!trimDrag}
|
||
showTrimHandles={!!onClipTrim}
|
||
onDragStart={handleDragStart}
|
||
onDragOver={handleDragOver}
|
||
onDragEnd={handleDragEnd}
|
||
onDrop={handleDrop}
|
||
onSelect={onClipSelect}
|
||
onContextMenu={handleContextMenu}
|
||
onMouseEnter={() => setHoveredClipId(clip.id)}
|
||
onMouseLeave={() => setHoveredClipId(null)}
|
||
onTrimHandleMouseDown={handleTrimHandleMouseDown}
|
||
onRemove={onClipRemove}
|
||
/>
|
||
))
|
||
)}
|
||
|
||
{/* ── 轨道末尾 "+" 添加卡片 ── */}
|
||
<div className="ep-track-add-card-wrapper">
|
||
<div
|
||
ref={addCardRef}
|
||
className="ep-track-add-card"
|
||
onClick={handleTogglePicker}
|
||
title="添加片段"
|
||
>
|
||
+
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 裁剪预览 tooltip */}
|
||
{trimPreview && (
|
||
<TrimPreview
|
||
startTime={trimPreview.startTime}
|
||
endTime={trimPreview.endTime}
|
||
duration={trimPreview.duration}
|
||
x={trimPreview.x}
|
||
y={trimPreview.y}
|
||
/>
|
||
)}
|
||
|
||
{/* 右键菜单 */}
|
||
{contextMenu && (
|
||
<ContextMenu
|
||
x={contextMenu.x}
|
||
y={contextMenu.y}
|
||
menuRef={contextMenuRef}
|
||
hasTrim={!!clips.find((c) => c.id === contextMenu.clipId)?.trim_config}
|
||
onSplit={handleContextSplit}
|
||
onResetTrim={handleContextResetTrim}
|
||
onDelete={handleContextDelete}
|
||
/>
|
||
)}
|
||
|
||
{/* 类型+时长选择面板 */}
|
||
{showAddPicker && (
|
||
<AddClipPicker
|
||
pickerRef={pickerRef}
|
||
position={pickerPos}
|
||
availableTypes={availableTypes}
|
||
addType={addType}
|
||
addDuration={addDuration}
|
||
onTypeChange={setAddType}
|
||
onDurationChange={setAddDuration}
|
||
onConfirm={handleConfirmAdd}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default TimelinePanel
|