78d1c88ca1
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m44s
CI/CD Pipeline / Unit Tests (push) Successful in 1m44s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m8s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web 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
847 lines
27 KiB
TypeScript
Executable File
847 lines
27 KiB
TypeScript
Executable File
/**
|
||
* 水平轨道时间线 — 支持裁剪手柄、分割、右键菜单
|
||
* 时间标尺(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/editPlans";
|
||
|
||
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<ClipType, string> = {
|
||
voice: "🎙️",
|
||
pip: "🖼️",
|
||
};
|
||
|
||
/** 片段类型标签 */
|
||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||
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<TimelinePanelProps> = ({
|
||
clips,
|
||
selectedClipId,
|
||
currentMode,
|
||
onClipSelect,
|
||
onClipReorder,
|
||
onClipRemove,
|
||
onAddClip,
|
||
onClipTrim,
|
||
onClipSplit,
|
||
onClipResetTrim,
|
||
currentTime = 0,
|
||
pixelsPerSecond = 40,
|
||
onZoomChange,
|
||
onSeek,
|
||
totalDuration: totalDurationProp,
|
||
}) => {
|
||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||
const dragRef = useRef<number | null>(null);
|
||
const [showAddPicker, setShowAddPicker] = useState(false);
|
||
const pickerRef = useRef<HTMLDivElement>(null);
|
||
const addCardRef = useRef<HTMLDivElement>(null);
|
||
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({
|
||
top: 0,
|
||
right: 0,
|
||
});
|
||
|
||
/* ── 裁剪拖拽状态 ── */
|
||
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null);
|
||
const [trimPreview, setTrimPreview] = useState<{
|
||
clipId: string;
|
||
startTime: number;
|
||
endTime: number;
|
||
duration: number;
|
||
x: number;
|
||
y: number;
|
||
} | null>(null);
|
||
|
||
/* ── 右键菜单 ── */
|
||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||
|
||
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
||
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null);
|
||
|
||
/* ── 播放头拖拽状态 ── */
|
||
const [playheadDragging, setPlayheadDragging] = useState(false);
|
||
const trackRef = useRef<HTMLDivElement>(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<ClipType>(defaultAddType);
|
||
const [addDuration, setAddDuration] = useState<number>(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<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);
|
||
}, []);
|
||
|
||
/* ── 确认添加片段 ── */
|
||
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 (
|
||
<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" && (
|
||
<div className="ep-time-ruler" onClick={handleRulerClick}>
|
||
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
|
||
{rulerMarks.map((t) => (
|
||
<span
|
||
key={t}
|
||
className="ep-time-mark"
|
||
style={{ left: `${t * pps}px` }}
|
||
>
|
||
{t}s
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 水平片段轨道 */}
|
||
{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) => {
|
||
/* 转场指示器 */
|
||
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 (
|
||
<React.Fragment key={clip.id}>
|
||
{/* 转场指示器 */}
|
||
{showTransition && transOpt && (
|
||
<div
|
||
className="ep-transition-indicator"
|
||
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
|
||
>
|
||
<span className="ep-trans-icon">{transOpt.icon}</span>
|
||
<span className="ep-trans-duration">
|
||
{trans!.duration.toFixed(1)}s
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
|
||
style={{ width: Math.max(clip.duration * pps, 60) }}
|
||
draggable={!trimDrag}
|
||
onDragStart={(e) => 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 && (
|
||
<div
|
||
className="ep-trim-handle ep-trim-handle-left"
|
||
onMouseDown={(e) =>
|
||
handleTrimHandleMouseDown(e, clip.id, "left")
|
||
}
|
||
title="拖动调整入点"
|
||
>
|
||
<div className="ep-trim-handle-line" />
|
||
</div>
|
||
)}
|
||
|
||
{/* 类型图标 */}
|
||
<div className="ep-clip-thumbnail">
|
||
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
|
||
</div>
|
||
|
||
{/* 片段信息 */}
|
||
<div className="ep-clip-info">
|
||
<span className="ep-clip-name">
|
||
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
|
||
</span>
|
||
<span className="ep-clip-duration">
|
||
{clip.duration}s
|
||
{hasTrim && (
|
||
<span className="ep-trim-indicator" title="已裁剪">
|
||
✂
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 速度徽章 */}
|
||
{showSpeed && (
|
||
<span className="ep-speed-badge">
|
||
{speed!.rate.toFixed(1)}x
|
||
</span>
|
||
)}
|
||
|
||
{/* 裁剪徽章 */}
|
||
{hasTrim && (
|
||
<span
|
||
className="ep-trim-badge"
|
||
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
|
||
>
|
||
✂
|
||
</span>
|
||
)}
|
||
|
||
{/* 右裁剪手柄 */}
|
||
{isHovered && onClipTrim && (
|
||
<div
|
||
className="ep-trim-handle ep-trim-handle-right"
|
||
onMouseDown={(e) =>
|
||
handleTrimHandleMouseDown(e, clip.id, "right")
|
||
}
|
||
title="拖动调整出点"
|
||
>
|
||
<div className="ep-trim-handle-line" />
|
||
</div>
|
||
)}
|
||
|
||
{/* 删除按钮 */}
|
||
<button
|
||
className="ep-clip-remove"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onClipRemove(clip.id);
|
||
}}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</React.Fragment>
|
||
);
|
||
})
|
||
)}
|
||
|
||
{/* ── 轨道末尾 "+" 添加卡片 ── */}
|
||
<div className="ep-track-add-card-wrapper">
|
||
<div
|
||
ref={addCardRef}
|
||
className="ep-track-add-card"
|
||
onClick={handleTogglePicker}
|
||
title="添加片段"
|
||
>
|
||
+
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 裁剪预览 tooltip */}
|
||
{trimPreview && (
|
||
<div
|
||
className="ep-trim-preview"
|
||
style={{
|
||
position: "fixed",
|
||
left: trimPreview.x + 12,
|
||
top: trimPreview.y - 40,
|
||
}}
|
||
>
|
||
<div className="ep-trim-preview-row">
|
||
<span className="ep-trim-preview-label">入点</span>
|
||
<span className="ep-trim-preview-value">
|
||
{formatTrimTime(trimPreview.startTime)}
|
||
</span>
|
||
</div>
|
||
<div className="ep-trim-preview-row">
|
||
<span className="ep-trim-preview-label">出点</span>
|
||
<span className="ep-trim-preview-value">
|
||
{formatTrimTime(trimPreview.endTime)}
|
||
</span>
|
||
</div>
|
||
<div className="ep-trim-preview-row ep-trim-preview-duration">
|
||
<span className="ep-trim-preview-label">时长</span>
|
||
<span className="ep-trim-preview-value">
|
||
{formatTrimTime(trimPreview.duration)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 右键菜单 */}
|
||
{contextMenu && (
|
||
<div
|
||
ref={contextMenuRef}
|
||
className="ep-context-menu"
|
||
style={{
|
||
position: "fixed",
|
||
left: contextMenu.x,
|
||
top: contextMenu.y,
|
||
}}
|
||
>
|
||
<div className="ep-context-menu-item" onClick={handleContextSplit}>
|
||
<span className="ep-context-menu-icon">✂️</span>
|
||
<span>分割片段</span>
|
||
</div>
|
||
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && (
|
||
<div
|
||
className="ep-context-menu-item"
|
||
onClick={handleContextResetTrim}
|
||
>
|
||
<span className="ep-context-menu-icon">↩️</span>
|
||
<span>恢复原始长度</span>
|
||
</div>
|
||
)}
|
||
<div className="ep-context-menu-divider" />
|
||
<div
|
||
className="ep-context-menu-item ep-context-menu-item-danger"
|
||
onClick={handleContextDelete}
|
||
>
|
||
<span className="ep-context-menu-icon">🗑️</span>
|
||
<span>删除片段</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 类型+时长选择面板 */}
|
||
{showAddPicker && (
|
||
<div
|
||
ref={pickerRef}
|
||
className="ep-add-clip-picker ep-add-clip-picker--portal"
|
||
style={{
|
||
position: "fixed",
|
||
top: pickerPos.top,
|
||
right: pickerPos.right,
|
||
}}
|
||
>
|
||
<div className="ep-add-clip-picker-title">添加片段</div>
|
||
|
||
{/* 类型选择 */}
|
||
<div className="ep-add-clip-type-row">
|
||
<span className="ep-add-clip-type-label">类型:</span>
|
||
{availableTypes.map((t) => (
|
||
<button
|
||
key={t}
|
||
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
|
||
onClick={() => setAddType(t)}
|
||
>
|
||
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* 时长输入 */}
|
||
<div className="ep-add-clip-duration-row">
|
||
<span className="ep-add-clip-type-label">时长:</span>
|
||
<input
|
||
type="number"
|
||
className="ep-duration-input"
|
||
min={1}
|
||
max={120}
|
||
value={addDuration}
|
||
onChange={(e) =>
|
||
setAddDuration(
|
||
Math.max(1, Math.min(120, Number(e.target.value) || 1)),
|
||
)
|
||
}
|
||
/>
|
||
<span className="ep-add-clip-duration-unit">秒</span>
|
||
</div>
|
||
|
||
{/* 确认按钮 */}
|
||
<button
|
||
className="ep-add-clip-confirm-btn"
|
||
onClick={handleConfirmAdd}
|
||
>
|
||
添加
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TimelinePanel;
|