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
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user