feat: 剪辑时间轴裁剪增强 — 手柄拖拽/分割/恢复原始长度
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 44s
CI/CD Pipeline / Unit Tests (push) Successful in 1m16s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 40s
CI/CD Pipeline / Frontend Lint (push) Failing after 1m6s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (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 / Unit Tests (pull_request) Successful in 1m17s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m21s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m53s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m51s

- 新增 TrimConfig 类型(入点/出点/原始时长)
- TimelinePanel 添加左右裁剪手柄(hover显示),拖动调整入点/出点
- 拖动时实时显示裁剪预览 tooltip(入点/出点/时长)
- 右键片段弹出菜单:分割片段 / 恢复原始长度 / 删除
- 已裁剪片段显示视觉标记(左侧蓝色边框 + ✂ 图标)
- EditingPlanner 新增 handleClipTrim/handleClipSplit/handleClipResetTrim
- handleSave/handleGoToGenerate payload 序列化 trim_config
- 新增 CSS 样式:裁剪手柄、预览 tooltip、右键菜单
This commit is contained in:
CI Bot
2026-07-14 07:59:57 +08:00
parent b08598290b
commit d91a803719
4 changed files with 572 additions and 32 deletions
@@ -983,6 +983,160 @@
clip-path: polygon(0 0, 100% 0, 50% 100%);
}
/* ── 裁剪手柄 ── */
.ep-trim-handle {
position: absolute;
top: 0;
bottom: 0;
width: 8px;
cursor: col-resize;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.ep-trim-handle-left {
left: 0;
border-radius: var(--radius-sm, 14px) 0 0 var(--radius-sm, 14px);
}
.ep-trim-handle-right {
right: 0;
border-radius: 0 var(--radius-sm, 14px) var(--radius-sm, 14px) 0;
}
.ep-trim-handle:hover {
background: rgba(99, 102, 241, 0.15);
}
.ep-trim-handle-line {
width: 2px;
height: 20px;
background: var(--primary, #6366f1);
border-radius: 1px;
opacity: 0.6;
}
.ep-trim-handle:hover .ep-trim-handle-line {
opacity: 1;
}
/* 已裁剪片段的视觉标记 */
.ep-clip-card.trimmed {
border-left: 3px solid var(--primary, #6366f1);
border-right: 3px solid var(--primary, #6366f1);
}
/* 裁剪指示器(时长旁的剪刀图标) */
.ep-trim-indicator {
margin-left: 2px;
font-size: 9px;
color: var(--primary, #6366f1);
}
/* 裁剪徽章 */
.ep-trim-badge {
position: absolute;
bottom: 4px;
right: 4px;
font-size: 9px;
color: var(--primary, #6366f1);
background: rgba(99, 102, 241, 0.1);
border-radius: 4px;
padding: 1px 3px;
line-height: 1;
}
/* 裁剪预览 tooltip */
.ep-trim-preview {
background: rgba(30, 41, 59, 0.92);
color: #fff;
border-radius: 8px;
padding: 8px 12px;
font-size: 12px;
z-index: 1000;
pointer-events: none;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
min-width: 120px;
}
.ep-trim-preview-row {
display: flex;
justify-content: space-between;
gap: 12px;
line-height: 1.6;
}
.ep-trim-preview-label {
color: rgba(255, 255, 255, 0.6);
font-size: 11px;
}
.ep-trim-preview-value {
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.ep-trim-preview-duration {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.15);
}
.ep-trim-preview-duration .ep-trim-preview-value {
color: #60a5fa;
}
/* ── 右键上下文菜单 ── */
.ep-context-menu {
background: var(--bg-primary, #ffffff);
border: 1px solid var(--border-color, #e2e2e2);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 2000;
min-width: 160px;
padding: 4px 0;
overflow: hidden;
}
.ep-context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font-size: 13px;
color: var(--text-primary, #1e293b);
cursor: pointer;
transition: background 0.15s;
}
.ep-context-menu-item:hover {
background: var(--bg-hover, #f1f5f9);
}
.ep-context-menu-item-danger {
color: #ef4444;
}
.ep-context-menu-item-danger:hover {
background: rgba(239, 68, 68, 0.06);
}
.ep-context-menu-icon {
font-size: 14px;
width: 18px;
text-align: center;
}
.ep-context-menu-divider {
height: 1px;
background: var(--border-color, #e2e2e2);
margin: 4px 0;
}
/* 空轨道 */
.ep-track-empty {
flex: 1;
@@ -30,7 +30,7 @@ import {
import { useUndoRedo } from "./hooks/useUndoRedo";
import type { TaskItem } from "@/api/tasks";
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
import type { ClipData, ClipType, TransitionConfig, SpeedConfig, TtsConfig } from "./types";
import type { ClipData, ClipType, TransitionConfig, SpeedConfig, TtsConfig, TrimConfig } from "./types";
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "./types";
import {
ensureDefaultLibrary,
@@ -372,6 +372,85 @@ const EditingPlanner: React.FC = () => {
[clips.length, setClips],
);
/* ── 裁剪更新:调整片段的 trim_config 和 duration ── */
const handleClipTrim = useCallback(
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId
? { ...c, trim_config: trimConfig, duration: newDuration }
: c,
),
);
},
[setClips],
);
/* ── 片段分割:在指定比例位置将片段一分为二 ── */
const handleClipSplit = useCallback(
(clipId: string, splitRatio: number) => {
setClips((prev) => {
const idx = prev.findIndex((c) => c.id === clipId);
if (idx === -1) return prev;
const clip = prev[idx];
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10;
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev;
// 前半段
const firstHalf: ClipData = {
...clip,
duration: splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
end_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
};
// 后半段
const secondHalf: ClipData = {
...clip,
id: `clip-${Date.now()}`,
duration: clip.duration - splitPoint,
startOffset: clip.startOffset + splitPoint,
trim_config: clip.trim_config
? {
...clip.trim_config,
start_time: clip.trim_config.start_time + splitPoint,
}
: undefined,
order: (clip.order ?? idx) + 1,
};
const updated = [...prev];
updated[idx] = firstHalf;
updated.splice(idx + 1, 0, secondHalf);
return updated.map((c, i) => ({ ...c, order: i }));
});
},
[setClips],
);
/* ── 恢复片段原始长度 ── */
const handleClipResetTrim = useCallback(
(clipId: string) => {
setClips((prev) =>
prev.map((c) => {
if (c.id !== clipId || !c.trim_config) return c;
const originalDuration =
c.trim_config.original_duration ?? c.duration;
return {
...c,
duration: originalDuration,
trim_config: undefined,
};
}),
);
},
[setClips],
);
/* ── 转场特效变更 ── */
const handleTransitionChange = useCallback(
(config: TransitionConfig) => {
@@ -527,6 +606,12 @@ const EditingPlanner: React.FC = () => {
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
};
if (loadedTemplateId) {
@@ -600,6 +685,12 @@ const EditingPlanner: React.FC = () => {
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
};
const params = new URLSearchParams();
@@ -821,6 +912,9 @@ const EditingPlanner: React.FC = () => {
onClipReorder={handleClipReorder}
onClipRemove={handleClipRemove}
onAddClip={handleAddClip}
onClipTrim={handleClipTrim}
onClipSplit={handleClipSplit}
onClipResetTrim={handleClipResetTrim}
/>
</div>
@@ -1,7 +1,12 @@
/**
* 水平轨道时间线 — 片段 = 时间规划 + 类型标记,不绑定素材
* 水平轨道时间线 — 支持裁剪手柄、分割、右键菜单
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
* "+" 卡片 → 类型+时长选择器
*
* 裁剪交互:
* - 鼠标悬停片段两端显示拖拽手柄,拖动调整入点/出点
* - 拖动时实时显示裁剪预览(入点/出点/时长)
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
*/
import React, {
useState,
@@ -11,7 +16,7 @@ import React, {
useLayoutEffect,
useMemo,
} from "react";
import type { ClipData, ClipType } from "../types";
import type { ClipData, ClipType, TrimConfig } from "../types";
import { TRANSITION_OPTIONS } from "@/api/editPlans";
interface TimelinePanelProps {
@@ -22,6 +27,12 @@ interface TimelinePanelProps {
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;
}
/** 片段类型图标 */
@@ -36,6 +47,25 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
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,
@@ -44,6 +74,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onClipReorder,
onClipRemove,
onAddClip,
onClipTrim,
onClipSplit,
onClipResetTrim,
}) => {
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
@@ -56,6 +89,24 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
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 availableTypes: ClipType[] = useMemo(
() =>
@@ -67,7 +118,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
[currentMode],
);
/* ── 默认添加类型:跟随模式(纯单类型模式直接用该类型,混合模式默认 voice) ── */
/* ── 默认添加类型:跟随模式 ── */
const defaultAddType: ClipType = useMemo(() => {
if (currentMode === "voice_over") return "voice";
if (currentMode === "pip") return "pip";
@@ -84,33 +135,28 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setAddType(defaultAddType);
}
}, [currentMode, addType, availableTypes, defaultAddType]);
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
const GAP = 6; // 面板与"+"卡片的间距
/* ── 计算 picker 初始位置(默认从"+"按钮上方弹出) ── */
/* ── 面板尺寸 ── */
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; // 粗略估算,useLayoutEffect 会用实际高度校正
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"
@@ -123,35 +169,27 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
setShowAddPicker((v) => !v);
};
/* ── 渲染后用实际 offsetHeight 做精确边界校正(useLayoutEffect 确保 paint 前完成) ── */
/* ── 渲染后精确边界校正 ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return;
const pickerEl = pickerRef.current;
const addRect = addCardRef.current.getBoundingClientRect();
const pickerH = pickerEl.offsetHeight; // 实际高度,不用硬编码
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;
/* 下方也溢出 → clamp */
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]);
@@ -168,6 +206,19 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
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 handleConfirmAdd = () => {
onAddClip(addType, addDuration);
@@ -176,6 +227,8 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
/* ── 片段拖拽排序 ── */
const handleDragStart = (e: React.DragEvent, idx: number) => {
// 如果正在裁剪拖拽,不允许排序拖拽
if (trimDrag) return;
dragRef.current = idx;
setDragIdx(idx);
e.dataTransfer.setData("application/x-clip-drag", String(idx));
@@ -211,6 +264,122 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
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 = 20; // 1px = 1/20 秒,控制拖拽灵敏度
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]);
/* ── 右键菜单 ── */
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 totalDuration = clips.reduce((s, c) => s + c.duration, 0);
const rulerMarks: number[] = [];
@@ -225,6 +394,11 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
return `${m}:${s.toString().padStart(2, "0")}`;
};
/** 格式化裁剪时间(精确到0.1秒) */
const formatTrimTime = (sec: number) => {
return `${sec.toFixed(1)}s`;
};
return (
<div className="ep-timeline-area">
{/* 时间线头部 */}
@@ -294,7 +468,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
</div>
) : (
clips.map((clip, idx) => {
/* 转场指示器:非首个片段且有非 none 转场时显示 */
/* 转场指示器 */
const trans = clip.transition;
const showTransition =
idx > 0 && trans && trans.type !== "none";
@@ -302,10 +476,14 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
? 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}>
{/* 转场指示器 */}
@@ -322,14 +500,28 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
)}
<div
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
draggable
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
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] || "🎬"}
@@ -340,7 +532,12 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">{clip.duration}s</span>
<span className="ep-clip-duration">
{clip.duration}s
{hasTrim && (
<span className="ep-trim-indicator" title="已裁剪"></span>
)}
</span>
</div>
{/* 速度徽章 */}
@@ -350,6 +547,24 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
</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"
@@ -366,7 +581,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
})
)}
{/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */}
{/* ── 轨道末尾 "+" 添加卡片 ── */}
<div className="ep-track-add-card-wrapper">
<div
ref={addCardRef}
@@ -380,7 +595,70 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
</div>
)}
{/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */}
{/* 裁剪预览 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}
@@ -89,6 +89,18 @@ export const DEFAULT_TTS_CONFIG: TtsConfig = {
subtitle_sync: true,
};
/* ──────── 裁剪配置 ──────── */
/** 片段裁剪配置 — 定义素材的入点/出点 */
export interface TrimConfig {
/** 入点(秒),素材原始时间轴上的起始位置 */
start_time: number;
/** 出点(秒),素材原始时间轴上的结束位置 */
end_time: number;
/** 素材原始总时长(秒),用于"恢复原始长度" */
original_duration?: number;
}
/* ──────── 片段数据 ──────── */
export interface ClipData {
@@ -110,4 +122,6 @@ export interface ClipData {
speed?: SpeedConfig;
/** TTS 配音配置 */
tts_config?: TtsConfig;
/** 裁剪配置 — 定义素材入点/出点 */
trim_config?: TrimConfig;
}