feat: 时间轴轨道缩放 + 播放头控制
CI/CD Pipeline / Frontend Lint (push) Failing after 1m2s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m1s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1539h48m30s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1539h48m32s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1539h48m30s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1539h48m32s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1539h48m30s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1539h48m58s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1539h49m0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1539h49m1s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1539h49m3s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 1539h49m3s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1540h20m7s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1540h20m37s

- EditingPlanner: 新增 currentTime/pixelsPerSecond 状态 + rAF 帧推进
- TimelinePanel: 缩放控件(−/滑块/+/标签)、播放头渲染/拖拽、标尺点击跳转
- TimelinePanel: clip 宽度按 duration*pps 动态计算,裁剪灵敏度同步缩放
- EditingPlanner.css: zoom 控件 + playhead 三角手柄 + clip min-width 适配
- 松耦合:PreviewPlayer 不修改,currentTime 仅驱动播放头位置

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
CI Bot
2026-07-14 11:39:40 +08:00
parent 6213569ec8
commit 82de158e35
3 changed files with 217 additions and 22 deletions
@@ -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;
}
/* ── 裁剪手柄 ── */
@@ -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<number | null>(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}
/>
</div>
@@ -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<TimelinePanelProps> = ({
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);
@@ -107,6 +122,10 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null);
/* ── 播放头拖拽状态 ── */
const [playheadDragging, setPlayheadDragging] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] = useMemo(
() =>
@@ -219,6 +238,56 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
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);
@@ -293,7 +362,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
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<TimelinePanelProps> = ({
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<TimelinePanelProps> = ({
}, [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<TimelinePanelProps> = ({
</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={() => {
@@ -435,21 +531,16 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 时间标尺 */}
{currentMode !== "one_take" && (
<div className="ep-time-ruler">
<div className="ep-time-ruler" onClick={handleRulerClick}>
<div
className="ep-time-ruler-inner"
style={{ width: Math.max(clips.length * 108, 300) }}
style={{ width: trackWidth }}
>
{rulerMarks.map((t) => (
<span
key={t}
className="ep-time-mark"
style={{
left:
totalDuration > 0
? `${(t / totalDuration) * clips.length * 108}px`
: `${t * 20}px`,
}}
style={{ left: `${t * pps}px` }}
>
{t}s
</span>
@@ -460,7 +551,17 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 水平片段轨道 */}
{currentMode !== "one_take" && (
<div className="ep-clip-track" onDragOver={handleEmptyDragOver}>
<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>
@@ -501,6 +602,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
<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)}