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