diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index b3a7f1125..8c3f3d9ec 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -864,10 +864,9 @@ border-radius: 3px; } -/* 水平片段卡片 — 统一高度64px、圆角、hover高亮 */ +/* 水平片段卡片 — 宽度由 inline style 按 duration * pixelsPerSecond 控制 */ .ep-clip-card { - width: 100px; - min-width: 100px; + min-width: 60px; height: 64px; background: var(--bg-primary, #ffffff); border: 1px solid var(--border-color, #e2e2e2); @@ -970,17 +969,68 @@ background: var(--primary, #6366f1); z-index: 10; pointer-events: none; + transition: left 0.05s linear; } -.ep-playhead::before { - content: ""; +.ep-playhead-handle { position: absolute; - top: 0; - left: -4px; - width: 10px; - height: 10px; + top: -2px; + left: -6px; + width: 14px; + height: 14px; background: var(--primary, #6366f1); clip-path: polygon(0 0, 100% 0, 50% 100%); + cursor: pointer; + pointer-events: auto; + transition: transform 0.1s; +} + +.ep-playhead-handle:hover { + transform: scale(1.3); +} + +/* 缩放控件 */ +.ep-timeline-zoom { + display: flex; + align-items: center; + gap: 6px; + margin-right: 12px; +} + +.ep-zoom-btn { + width: 24px; + height: 24px; + border: 1px solid var(--line, #e2e8f0); + border-radius: var(--radius-xs, 8px); + background: var(--bg-secondary, #f8fafc); + color: var(--text-muted, #94a3b8); + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; + padding: 0; +} + +.ep-zoom-btn:hover { + border-color: var(--primary, #6366f1); + color: var(--primary, #6366f1); + background: var(--primary-soft, #eef2ff); +} + +.ep-zoom-slider { + width: 80px; + height: 4px; + accent-color: var(--primary, #6366f1); + cursor: pointer; +} + +.ep-zoom-label { + font-size: 11px; + color: var(--text-tertiary, #94a3b8); + min-width: 42px; + text-align: center; } /* ── 裁剪手柄 ── */ diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index aa0487042..b384de2b8 100755 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -2,7 +2,7 @@ * 剪辑计划编辑器 — V8 原型 1:1 还原 * 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px) */ -import React, { useState, useCallback, useEffect } from "react"; +import React, { useState, useCallback, useEffect, useRef } from "react"; import { useSearchParams, useNavigate } from "react-router-dom"; import { message } from "antd"; import { useQuery } from "@tanstack/react-query"; @@ -215,6 +215,19 @@ const EditingPlanner: React.FC = () => { /* ── 播放 ── */ const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [pixelsPerSecond, setPixelsPerSecond] = useState(40); + const prevFrameTimeRef = useRef(null); + + /** 播放头跳转 */ + const handleSeek = useCallback((time: number) => { + setCurrentTime(Math.max(0, time)); + }, []); + + /** 轨道缩放 */ + const handleZoomChange = useCallback((pps: number) => { + setPixelsPerSecond(pps); + }, []); /* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */ const voiceMaterialsQuery = useQuery({ @@ -335,6 +348,31 @@ const EditingPlanner: React.FC = () => { const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0); const selectedClip = clips.find((c) => c.id === selectedClipId) || null; + /* rAF 帧推进 — 播放时平滑更新播放头位置 */ + useEffect(() => { + if (!isPlaying) { + prevFrameTimeRef.current = null; + return; + } + let rafId: number; + const tick = (timestamp: number) => { + if (prevFrameTimeRef.current !== null) { + const delta = (timestamp - prevFrameTimeRef.current) / 1000; + setCurrentTime((prev) => { + const next = prev + delta; + return next >= totalDuration ? totalDuration : next; + }); + } + prevFrameTimeRef.current = timestamp; + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(rafId); + prevFrameTimeRef.current = null; + }; + }, [isPlaying, totalDuration]); + const filteredTemplates = templates.filter((t) => { if (currentFilter !== "全部" && t.category !== currentFilter) return false; if ( @@ -1018,6 +1056,11 @@ const EditingPlanner: React.FC = () => { onClipTrim={handleClipTrim} onClipSplit={handleClipSplit} onClipResetTrim={handleClipResetTrim} + currentTime={currentTime} + pixelsPerSecond={pixelsPerSecond} + onZoomChange={handleZoomChange} + onSeek={handleSeek} + totalDuration={totalDuration} /> diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index a08d8c08f..c174db707 100755 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -33,6 +33,16 @@ interface TimelinePanelProps { 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; } /** 片段类型图标 */ @@ -77,6 +87,11 @@ const TimelinePanel: React.FC = ({ onClipTrim, onClipSplit, onClipResetTrim, + currentTime = 0, + pixelsPerSecond = 40, + onZoomChange, + onSeek, + totalDuration: totalDurationProp, }) => { const [dragIdx, setDragIdx] = useState(null); const [dragOverIdx, setDragOverIdx] = useState(null); @@ -107,6 +122,10 @@ const TimelinePanel: React.FC = ({ /* ── 悬停的片段 ID(显示裁剪手柄) ── */ const [hoveredClipId, setHoveredClipId] = useState(null); + /* ── 播放头拖拽状态 ── */ + const [playheadDragging, setPlayheadDragging] = useState(false); + const trackRef = useRef(null); + /* ── 根据模式决定可选类型 ── */ const availableTypes: ClipType[] = useMemo( () => @@ -219,6 +238,56 @@ const TimelinePanel: React.FC = ({ 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) => { + 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); @@ -293,7 +362,7 @@ const TimelinePanel: React.FC = ({ useEffect(() => { if (!trimDrag) return; - const PX_PER_SECOND = 20; // 1px = 1/20 秒,控制拖拽灵敏度 + const PX_PER_SECOND = pixelsPerSecond ?? 40; // 与缩放级别同步 const handleMouseMove = (e: MouseEvent) => { const dx = e.clientX - trimDrag.startX; @@ -345,7 +414,7 @@ const TimelinePanel: React.FC = ({ document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; - }, [trimDrag, trimPreview, clips, onClipTrim]); + }, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond]); /* ── 右键菜单 ── */ const handleContextMenu = useCallback( @@ -381,7 +450,7 @@ const TimelinePanel: React.FC = ({ }, [contextMenu, onClipRemove]); /* ── 时间标尺 ── */ - const totalDuration = clips.reduce((s, c) => s + c.duration, 0); + 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) { @@ -410,6 +479,33 @@ const TimelinePanel: React.FC = ({
+ {/* 缩放控件 */} +
+ + onZoomChange?.(Number(e.target.value))} + /> + + {pps}px/s +