diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index bf65a4587..2100af1d0 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -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; diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index 4bf9f3821..98c8c68ea 100755 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -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} /> diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index 6383003de..a08d8c08f 100755 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -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 = { 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, @@ -44,6 +74,9 @@ const TimelinePanel: React.FC = ({ onClipReorder, onClipRemove, onAddClip, + onClipTrim, + onClipSplit, + onClipResetTrim, }) => { const [dragIdx, setDragIdx] = useState(null); const [dragOverIdx, setDragOverIdx] = useState(null); @@ -56,6 +89,24 @@ const TimelinePanel: React.FC = ({ 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 availableTypes: ClipType[] = useMemo( () => @@ -67,7 +118,7 @@ const TimelinePanel: React.FC = ({ [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 = ({ 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 = ({ 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 = ({ 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 = ({ /* ── 片段拖拽排序 ── */ 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 = ({ 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 = ({ return `${m}:${s.toString().padStart(2, "0")}`; }; + /** 格式化裁剪时间(精确到0.1秒) */ + const formatTrimTime = (sec: number) => { + return `${sec.toFixed(1)}s`; + }; + return (
{/* 时间线头部 */} @@ -294,7 +468,7 @@ const TimelinePanel: React.FC = ({
) : ( 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 = ({ ? 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 ( {/* 转场指示器 */} @@ -322,14 +500,28 @@ const TimelinePanel: React.FC = ({ )}
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] || "🎬"} @@ -340,7 +532,12 @@ const TimelinePanel: React.FC = ({ {CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1} - {clip.duration}s + + {clip.duration}s + {hasTrim && ( + + )} +
{/* 速度徽章 */} @@ -350,6 +547,24 @@ const TimelinePanel: React.FC = ({ )} + {/* 裁剪徽章 */} + {hasTrim && ( + + ✂ + + )} + + {/* 右裁剪手柄 */} + {isHovered && onClipTrim && ( +
handleTrimHandleMouseDown(e, clip.id, "right")} + title="拖动调整出点" + > +
+
+ )} + {/* 删除按钮 */}