feat(timeline): 支持角色轨迹关键帧与轨道拖拽排序

This commit is contained in:
2026-09-17 14:47:29 +08:00
parent 0794486857
commit bab92c05b8
18 changed files with 1862 additions and 135 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type {
CSSProperties,
KeyboardEvent as ReactKeyboardEvent,
@@ -8,6 +8,7 @@ import {
Box,
Camera,
ChevronDown,
ChevronRight,
DiamondPlus,
Eraser,
Film,
@@ -18,11 +19,14 @@ import {
RefreshCw,
SkipBack,
SkipForward,
Square,
Trash2,
Video,
type LucideIcon,
} from "lucide-react";
import type { CameraKeyframeValue, KeyframeTransformValue } from "../schema/timeline";
import type { MotionKind } from "../schema/directorProject";
import { getTrajectoryPointTime } from "../schema/trajectoryMotion";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { exportVideo, type VideoExportView } from "../io/videoExport";
@@ -37,25 +41,52 @@ function formatTime(t: number) {
return `${t.toFixed(1)}s`;
}
/** 读数里的时间:整秒省掉小数位(1s 而不是 1.0s),非整秒保留一位 */
function formatReadoutTime(t: number) {
const safeTime = Number.isFinite(t) ? t : 0;
return `${Number.isInteger(safeTime) ? safeTime : safeTime.toFixed(1)}s`;
}
/** 刻度候选步长(秒)。先按目标刻度数算出「每个刻度大约多少秒」,再取不小于它的最小候选值,
* 这样 5s / 8s / 30s 等不同时长都能得到疏密稳定的刻度。 */
const TIMELINE_RULER_STEP_CANDIDATES = [0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
const TIMELINE_RULER_TARGET_TICKS = 8;
/** 主刻度之间的细刻度步长(0.1s):介于两条主刻度之间,读数更精细 */
export const TIMELINE_RULER_MINOR_STEP = 0.1;
/** 细刻度数量上限:再密就糊成一条色带、没有读数意义,此时干脆不画 */
const TIMELINE_RULER_MINOR_MAX_COUNT = 200;
/** 时间轴帧率:读数里的帧号按它换算,与视频导出帧率保持一致 */
export const TIMELINE_FPS = 30;
/** 把秒换算成帧号(四舍五入到最近的一帧) */
export function getTimelineFrame(time: number): number {
const safeTime = Number.isFinite(time) ? Math.max(0, time) : 0;
return Math.round(safeTime * TIMELINE_FPS);
}
export interface TimelineRulerTick {
value: number;
percent: number;
}
/** 主刻度步长(秒):时长越长步长越粗,保证刻度数量稳定在目标值附近 */
export function getTimelineRulerStep(duration: number): number {
const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
if (safeDuration === 0) return 0;
const roughStep = safeDuration / TIMELINE_RULER_TARGET_TICKS;
return (
TIMELINE_RULER_STEP_CANDIDATES.find((candidate) => candidate >= roughStep) ??
TIMELINE_RULER_STEP_CANDIDATES[TIMELINE_RULER_STEP_CANDIDATES.length - 1]
);
}
export function getTimelineRulerTicks(duration: number): TimelineRulerTick[] {
const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
if (safeDuration === 0) return [{ value: 0, percent: 0 }];
const roughStep = safeDuration / TIMELINE_RULER_TARGET_TICKS;
const step =
TIMELINE_RULER_STEP_CANDIDATES.find((candidate) => candidate >= roughStep) ??
TIMELINE_RULER_STEP_CANDIDATES[TIMELINE_RULER_STEP_CANDIDATES.length - 1];
const step = getTimelineRulerStep(safeDuration);
const ticks: TimelineRulerTick[] = [];
for (let value = 0; value <= safeDuration + 1e-6; value += step) {
const rounded = Number(value.toFixed(4));
@@ -71,6 +102,33 @@ export function getTimelineRulerTicks(duration: number): TimelineRulerTick[] {
return ticks;
}
/**
* 主刻度之间的 0.1s 细刻度(比秒刻度更矮更淡,只画线不写数字)。
* - 主刻度本身已经细到 0.1s:不用再插细刻度;
* - 与主刻度重合的位置由主刻度负责,不重复画;
* - 时间轴太长(细刻度会糊成一片)时不画。
*/
export function getTimelineRulerMinorTicks(duration: number, majorStep: number): TimelineRulerTick[] {
const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
if (safeDuration === 0 || majorStep <= TIMELINE_RULER_MINOR_STEP + 1e-9) return [];
const total = Math.floor(safeDuration / TIMELINE_RULER_MINOR_STEP + 1e-6);
if (total > TIMELINE_RULER_MINOR_MAX_COUNT) return [];
const ticks: TimelineRulerTick[] = [];
for (let index = 1; index <= total; index += 1) {
const value = Number((index * TIMELINE_RULER_MINOR_STEP).toFixed(4));
if (value >= safeDuration - 1e-6) break;
const majorRatio = value / majorStep;
if (Math.abs(majorRatio - Math.round(majorRatio)) < 1e-6) continue;
ticks.push({ value, percent: (value / safeDuration) * 100 });
}
return ticks;
}
function formatRulerTick(value: number) {
return `${Number.isInteger(value) ? value : value.toFixed(1)}s`;
}
@@ -82,6 +140,116 @@ const OBJECT_KIND_ICONS: Record<string, LucideIcon> = {
prop: Box,
};
/** 人物轨道「动作」子轨道里展示的基础动作名 */
const MOTION_LABELS: Record<MotionKind, string> = {
none: "无动作",
idle: "待机",
walk: "行走",
run: "跑步",
};
type TimelineTrackKind = "object" | "camera" | "character";
/** 轨道拖拽的启动阈值(px):小于它当作普通点击,避免抢走关键帧的选中点击 */
const TRACK_DRAG_THRESHOLD_PX = 4;
/** 轨道行的纵向几何(拖拽开始时量一次就缓存,移动中只查表) */
interface TrackRowRect {
top: number;
bottom: number;
height: number;
}
/** 时间轴轨道的稳定标识:三种轨道 id 各自命名空间,避免同名 id 撞车 */
function getTimelineTrackKey(kind: TimelineTrackKind, id: string) {
return `${kind}:${id}`;
}
/**
* 解析轨道的最终显示顺序:
* 1. 先按用户保存过的顺序排列(过滤掉已删除的轨道、去重);
* 2. 没被记录过的新轨道(新增对象 / 相机 / 角色)按默认顺序追加在后面。
*/
export function resolveTimelineTrackOrder(defaultOrder: string[], savedOrder: string[]): string[] {
const validKeys = new Set(defaultOrder);
const seen = new Set<string>();
const resolved: string[] = [];
savedOrder.forEach((key) => {
if (!validKeys.has(key) || seen.has(key)) return;
seen.add(key);
resolved.push(key);
});
defaultOrder.forEach((key) => {
if (seen.has(key)) return;
seen.add(key);
resolved.push(key);
});
return resolved;
}
/** 拖拽落点:落在目标轨道的上方还是下方 */
export type TimelineTrackDropPosition = "before" | "after";
/**
* 把 sourceKey 移动到 targetKey 的前 / 后(拖拽排序)。
* 找不到轨道、或移动后顺序没变时原样返回(调用方据此跳过写入)。
*/
export function reorderTimelineTrackKeys(
order: string[],
sourceKey: string,
targetKey: string,
position: TimelineTrackDropPosition
): string[] {
if (sourceKey === targetKey) return order;
if (!order.includes(sourceKey) || !order.includes(targetKey)) return order;
const withoutSource = order.filter((key) => key !== sourceKey);
const targetIndex = withoutSource.indexOf(targetKey);
const insertIndex = position === "after" ? targetIndex + 1 : targetIndex;
if (insertIndex === order.indexOf(sourceKey)) return order;
return [...withoutSource.slice(0, insertIndex), sourceKey, ...withoutSource.slice(insertIndex)];
}
/** 轨道行左侧的折叠开关:所有轨道都能折叠(有子轨道的收起子轨道,没有的收起关键帧车道) */
function TimelineTrackCollapseToggle({
trackLabel,
trackKindLabel,
collapsed,
onToggle,
}: {
trackLabel: string;
/** 轨道种类(物体 / 运镜 / 角色),用于区分同名轨道,让无障碍标签唯一 */
trackKindLabel: string;
collapsed: boolean;
onToggle: () => void;
}) {
const actionLabel = `${collapsed ? "展开" : "折叠"}${trackKindLabel}(${trackLabel})`;
return (
<button
className="timeline-track-icon-button timeline-track-collapse-button"
type="button"
aria-expanded={!collapsed}
aria-label={actionLabel}
title={collapsed ? "展开轨道" : "折叠轨道"}
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
>
{collapsed ? (
<ChevronRight aria-hidden="true" size={13} strokeWidth={2.2} />
) : (
<ChevronDown aria-hidden="true" size={13} strokeWidth={2.2} />
)}
</button>
);
}
export function TimelinePanel() {
const project = useDirectorStore((state) => state.project);
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
@@ -96,17 +264,41 @@ export function TimelinePanel() {
const applyCameraKeyframeToShot = useDirectorStore((state) => state.applyCameraKeyframeToShot);
const setTimelineDuration = useDirectorStore((state) => state.setTimelineDuration);
const clearAllKeyframes = useDirectorStore((state) => state.clearAllKeyframes);
const selectedTrajectoryPoint = useDirectorStore((state) => state.selectedTrajectoryPoint);
const selectTrajectoryPoint = useDirectorStore((state) => state.selectTrajectoryPoint);
const removeTrajectoryPoint = useDirectorStore((state) => state.removeTrajectoryPoint);
const timelineTrackOrder = useDirectorStore((state) => state.timelineTrackOrder);
const timelineCollapsedTracks = useDirectorStore((state) => state.timelineCollapsedTracks);
const setTimelineTrackOrder = useDirectorStore((state) => state.setTimelineTrackOrder);
const toggleTimelineTrackCollapsed = useDirectorStore((state) => state.toggleTimelineTrackCollapsed);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const playhead = usePlaybackStore((state) => state.playhead);
const duration = usePlaybackStore((state) => state.duration);
const togglePlaying = usePlaybackStore((state) => state.togglePlaying);
const stopPlayback = usePlaybackStore((state) => state.stop);
const setPlaying = usePlaybackStore((state) => state.setPlaying);
const setPlayhead = usePlaybackStore((state) => state.setPlayhead);
const [selectedKeyframe, setSelectedKeyframe] = useState<SelectedKeyframe | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
// 轨道拖拽:生命周期(谁在拖 → 高亮 / 毛玻璃降级)走 React state,一次拖拽只重渲染两次;
// 高频的落点线则完全不经过 React(ref + transform),详见 getTrackDragProps。
const [draggingTrackKey, setDraggingTrackKey] = useState<string | null>(null);
const dropTargetRef = useRef<{ key: string; position: TimelineTrackDropPosition } | null>(null);
const dropIndicatorRef = useRef<HTMLDivElement | null>(null);
/** 当前这一次指针手势(按下 → 超过阈值才算真的开始拖拽,否则当普通点击放行) */
const dragGestureRef = useRef<{
pointerId: number;
sourceKey: string;
active: boolean;
startX: number;
startY: number;
} | null>(null);
/** 拖拽开始时一次性量好的行几何:移动中只查表,绝不在事件里读布局(强制同步布局 = layout thrashing) */
const trackRectsRef = useRef(new Map<string, TrackRowRect>());
const trackLayoutOriginRef = useRef(0);
const panelRef = useRef<HTMLElement | null>(null);
const exportMenuRef = useRef<HTMLDivElement | null>(null);
@@ -232,6 +424,13 @@ export function TimelinePanel() {
setSelectedKeyframe(null);
}
/** 人物轨迹上的「轨迹关键帧」:轨迹点本身就是关键帧,点它即选中对应轨迹点并把指针定位到该点时间 */
function handleSelectTrajectoryKeyframe(objectId: string, pointId: string, time: number) {
setPlaying(false);
setPlayhead(time);
selectTrajectoryPoint(objectId, pointId);
}
function handleClearAll() {
if (timeline.tracks.length === 0 && timeline.cameraTracks.length === 0) return;
if (window.confirm("确定清空所有关键帧吗?此操作可用撤销恢复。")) {
@@ -254,20 +453,236 @@ export function TimelinePanel() {
// 刻度尺本身就是时间指针:点击 / 拖动定位,方向键 / Home / End 微调
const rulerRef = useRef<HTMLDivElement | null>(null);
const rulerRectRef = useRef<DOMRect | null>(null);
const [isScrubbing, setIsScrubbing] = useState(false);
const rulerTicks = getTimelineRulerTicks(duration);
const rulerTicks = useMemo(() => getTimelineRulerTicks(duration), [duration]);
const rulerMinorTicks = useMemo(
() => getTimelineRulerMinorTicks(duration, getTimelineRulerStep(duration)),
[duration]
);
// 刻度节点缓存住:拖拽 / 擦洗导致的重渲染不会再去 diff 上百个刻度元素
const rulerTickNodes = useMemo(
() => (
<>
{rulerMinorTicks.map((tick) => (
<span
className="timeline-ruler-minor-tick"
key={`minor-${tick.value}`}
style={{ left: `${tick.percent}%` }}
/>
))}
{rulerTicks.map((tick) => (
<span className="timeline-ruler-tick" key={tick.value} style={{ left: `${tick.percent}%` }}>
{formatRulerTick(tick.value)}
</span>
))}
</>
),
[rulerMinorTicks, rulerTicks]
);
const playheadFrame = getTimelineFrame(playhead);
const totalFrames = getTimelineFrame(duration);
const playheadPercent = duration === 0 ? 0 : Math.min(100, Math.max(0, (playhead / duration) * 100));
const hasKeyframes = timeline.tracks.length > 0 || timeline.cameraTracks.length > 0;
// 相邻刻度间距(百分比)交给 CSS:轨道底色用它画贯穿网格线,与刻度尺天然对齐
const tickPercent = rulerTicks.length > 1 ? rulerTicks[1].percent : 100;
const gridStyle = { "--timeline-tick-percent": `${tickPercent}%` } as CSSProperties;
// 人物轨道:每个角色一条,内含「动作」与「人物轨迹」两条子轨道
const characterObjects = project.objects.filter((item) => item.kind === "character");
// 轨道顺序:默认「物体轨道 → 相机运镜轨道 → 人物轨道」,用户可以上移 / 下移调整。
// 视觉顺序靠 flex order 实现,DOM 顺序保持稳定(不影响既有查询与无障碍阅读顺序)。
const defaultTrackKeys = [
...timeline.tracks.map((track) => getTimelineTrackKey("object", track.objectId)),
...timeline.cameraTracks.map((track) => getTimelineTrackKey("camera", track.cameraId)),
...characterObjects.map((item) => getTimelineTrackKey("character", item.id)),
];
const orderedTrackKeys = resolveTimelineTrackOrder(defaultTrackKeys, timelineTrackOrder);
const trackOrderIndex = new Map(orderedTrackKeys.map((key, index) => [key, index]));
function getTrackOrder(key: string) {
return trackOrderIndex.get(key) ?? 0;
}
function renderCollapseToggle(trackKey: string, trackLabel: string, trackKindLabel: string, collapsed: boolean) {
return (
<TimelineTrackCollapseToggle
trackLabel={trackLabel}
trackKindLabel={trackKindLabel}
collapsed={collapsed}
onToggle={() => toggleTimelineTrackCollapsed(trackKey)}
/>
);
}
/** 拖拽开始时量一次所有轨道的几何(此后拖拽期间不再读布局) */
function measureTrackRows() {
const panel = panelRef.current;
const body = panel?.querySelector<HTMLElement>(".timeline-grid-body");
trackLayoutOriginRef.current = body ? body.getBoundingClientRect().top : 0;
const rects = new Map<string, { top: number; bottom: number; height: number }>();
panel?.querySelectorAll<HTMLElement>("[data-track-key]").forEach((row) => {
const key = row.dataset.trackKey;
if (!key) return;
const rect = row.getBoundingClientRect();
rects.set(key, { top: rect.top, bottom: rect.bottom, height: rect.height });
});
trackRectsRef.current = rects;
}
/** 把落点线移到目标行的上 / 下边缘:只改 transform(合成层位移),不触发行重绘 */
function showDropIndicator(rowKey: string, position: TimelineTrackDropPosition) {
const indicator = dropIndicatorRef.current;
const rect = trackRectsRef.current.get(rowKey);
if (!indicator || !rect) return;
const y = (position === "before" ? rect.top : rect.bottom) - trackLayoutOriginRef.current;
indicator.style.transform = `translateY(${y}px)`;
indicator.classList.add("is-visible");
}
function hideDropIndicator() {
dropIndicatorRef.current?.classList.remove("is-visible");
}
/** 收尾:落点线、拖拽状态、拖拽期视觉降级一并还原 */
function clearTrackDragStyles() {
dropTargetRef.current = null;
trackRectsRef.current = new Map();
dragGestureRef.current = null;
hideDropIndicator();
setDraggingTrackKey(null);
document.body.classList.remove("is-timeline-dragging");
}
/** 行内真正的按钮 / 输入框自己处理交互,不抢来做轨道拖拽(关键帧等 role=button 的块仍可当拖拽把手) */
function isTrackDragBlockedTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest("button, input, textarea, select, a"));
}
/** 由指针位置找落点行:优先命中的行,都没命中就取最近的一行(拖到末尾之外也能落到最后一行) */
function findDropTargetAt(clientY: number) {
let hit: { key: string; rect: TrackRowRect } | null = null;
let nearest: { key: string; rect: TrackRowRect } | null = null;
let nearestDistance = Number.POSITIVE_INFINITY;
for (const [key, rect] of trackRectsRef.current) {
if (clientY >= rect.top && clientY <= rect.bottom) {
hit = { key, rect };
break;
}
const distance = Math.min(Math.abs(clientY - rect.top), Math.abs(clientY - rect.bottom));
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = { key, rect };
}
}
const target = hit ?? nearest;
if (!target) return null;
const { key, rect } = target;
const position: TimelineTrackDropPosition = clientY - rect.top < rect.height / 2 ? "before" : "after";
return { key, position };
}
function handleTrackPointerDown(event: ReactPointerEvent<HTMLDivElement>, trackKey: string) {
if (event.button !== 0) return;
if (isTrackDragBlockedTarget(event.target)) return;
dragGestureRef.current = {
pointerId: event.pointerId,
sourceKey: trackKey,
active: false,
startX: event.clientX,
startY: event.clientY,
};
}
function handleTrackPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
const gesture = dragGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
if (!gesture.active) {
// 没超过阈值就是在普通点击(选关键帧等),不能抢走交互
const moved = Math.abs(event.clientY - gesture.startY) + Math.abs(event.clientX - gesture.startX);
if (moved < TRACK_DRAG_THRESHOLD_PX) return;
gesture.active = true;
// 量一次行几何,之后拖拽期间只查表;同时开启拖拽期视觉降级
measureTrackRows();
dropTargetRef.current = null;
setDraggingTrackKey(gesture.sourceKey);
document.body.classList.add("is-timeline-dragging");
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// jsdom 等环境不支持 pointer capture,忽略后仍能继续拖动
}
}
event.preventDefault();
const target = findDropTargetAt(event.clientY);
if (!target) return;
if (dropTargetRef.current?.key === target.key && dropTargetRef.current.position === target.position) return;
dropTargetRef.current = target;
showDropIndicator(target.key, target.position);
}
function handleTrackPointerUp(event: ReactPointerEvent<HTMLDivElement>) {
const gesture = dragGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
const { active, sourceKey } = gesture;
const target = dropTargetRef.current;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// 同上,忽略不支持的环境
}
clearTrackDragStyles();
if (!active || !target) return;
const nextOrder = reorderTimelineTrackKeys(orderedTrackKeys, sourceKey, target.key, target.position);
if (nextOrder === orderedTrackKeys) return;
setTimelineTrackOrder(nextOrder);
}
/**
* 整行作为一个拖拽单元:按住拖动,落到目标轨道上方 / 下方即完成重排。
*
* 这里刻意**不用 HTML5 拖拽(draggable + dragstart/dragover/drop)**:原生拖拽会把 drag image
* 快照、dragover 风暴、命中测试等一整套开销交给浏览器,我们既控不了频率也控不了代价,
* 在「全屏 WebGL 画布 + 毛玻璃悬浮面板」这种页面里就是拖一次卡一次。
* 改用 pointer events 自己实现后,每次指针移动只做:查一次缓存表 + 可能移动一条 transform 线。
*/
function getTrackDragProps(trackKey: string) {
return {
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => handleTrackPointerDown(event, trackKey),
onPointerMove: handleTrackPointerMove,
onPointerUp: handleTrackPointerUp,
onPointerCancel: handleTrackPointerUp,
};
}
function getTimeFromPointer(clientX: number) {
const element = rulerRef.current;
if (!element) return null;
const rect = element.getBoundingClientRect();
// 拖动播放头时每次 pointermove 都读 rect 会强制同步布局(拖动最常见的卡顿来源),
// 一次拖动内刻度尺不会移动,缓存一次即可。
const rect = rulerRectRef.current ?? element.getBoundingClientRect();
rulerRectRef.current = rect;
if (rect.width <= 0) return null;
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
@@ -281,6 +696,8 @@ export function TimelinePanel() {
}
function handleRulerPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
// 每次开始拖动重新量一次刻度尺(窗口可能刚被缩放)
rulerRectRef.current = null;
const time = getTimeFromPointer(event.clientX);
if (time === null) return;
@@ -306,6 +723,7 @@ export function TimelinePanel() {
if (!isScrubbing) return;
setIsScrubbing(false);
rulerRectRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
@@ -339,14 +757,27 @@ export function TimelinePanel() {
: "请先选择一个对象或相机";
return (
<section className="timeline-panel" aria-label="时间轴" ref={panelRef}>
<section
className={`timeline-panel${draggingTrackKey ? " is-track-dragging" : ""}`}
aria-label="时间轴"
ref={panelRef}
>
<div className="timeline-toolbar">
<div className="timeline-toolbar-group">
<span className="timeline-time-readout">
{formatTime(playhead)} / {formatTime(duration)}
{/* 读数 = 当前帧号 / 当前时间;总时长单独放在右边的时长输入框里 */}
<span
className="timeline-time-readout"
title={`当前第 ${playheadFrame} 帧 / 共 ${totalFrames} 帧(${TIMELINE_FPS}fps)`}
>
<span className="timeline-time-readout-frame">F{playheadFrame}</span>
<span className="timeline-time-readout-divider" aria-hidden="true">
/
</span>
<span className="timeline-time-readout-clock">{formatReadoutTime(playhead)}</span>
</span>
<label className="timeline-duration-chip" title="时间轴总时长(秒)">
<MoveHorizontal aria-hidden="true" size={14} strokeWidth={1.8} />
<span className="timeline-duration-caption">总时长</span>
<input
className="timeline-duration-input"
aria-label="时间轴总时长"
@@ -384,6 +815,15 @@ export function TimelinePanel() {
<Play aria-hidden="true" size={15} strokeWidth={1.8} />
)}
</button>
<button
className="timeline-icon-button"
type="button"
aria-label="停止"
title="停止播放并回到起点"
onClick={stopPlayback}
>
<Square aria-hidden="true" size={13} strokeWidth={2} />
</button>
<button
className="timeline-icon-button"
type="button"
@@ -505,11 +945,8 @@ export function TimelinePanel() {
onPointerUp={handleRulerPointerEnd}
onPointerCancel={handleRulerPointerEnd}
>
{rulerTicks.map((tick) => (
<span className="timeline-ruler-tick" key={tick.value} style={{ left: `${tick.percent}%` }}>
{formatRulerTick(tick.value)}
</span>
))}
{/* 0.1s 细刻度先画,主刻度的文字与竖线压在上面 */}
{rulerTickNodes}
</div>
</div>
@@ -518,46 +955,62 @@ export function TimelinePanel() {
const obj = project.objects.find((o) => o.id === track.objectId);
const KindIcon = (obj ? OBJECT_KIND_ICONS[obj.kind] : undefined) ?? Box;
const trackName = obj?.name ?? track.objectId;
const trackKey = getTimelineTrackKey("object", track.objectId);
const collapsed = timelineCollapsedTracks.includes(trackKey);
const dragProps = getTrackDragProps(trackKey);
return (
<div className="timeline-track" key={track.objectId}>
<div className="timeline-track-label" title={trackName}>
<div
className={`timeline-track${collapsed ? " is-collapsed" : ""}${
draggingTrackKey === trackKey ? " is-dragging" : ""
}`}
key={track.objectId}
data-track-key={trackKey}
style={{ order: getTrackOrder(trackKey) }}
{...dragProps}
>
<div className="timeline-track-label" title={`${trackName} · 拖动整行可调整轨道顺序`}>
{renderCollapseToggle(trackKey, trackName, "物体轨道", collapsed)}
<KindIcon aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">{trackName}</span>
<span className="timeline-track-count">{track.keyframes.length}</span>
</div>
<div className="timeline-track-lane">
{track.keyframes.map((kf) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "object" &&
selectedKeyframe.targetId === track.objectId &&
selectedKeyframe.keyframeId === kf.id;
return (
<div
key={kf.id}
className={`timeline-keyframe${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`关键帧 ${formatTime(kf.time)}`}
onClick={() => handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeKeyframe(track.objectId, kf.id);
}}
{collapsed ? (
<span className="timeline-track-summary">{track.keyframes.length} 个关键帧</span>
) : (
track.keyframes.map((kf) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "object" &&
selectedKeyframe.targetId === track.objectId &&
selectedKeyframe.keyframeId === kf.id;
return (
<div
key={kf.id}
className={`timeline-keyframe${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`关键帧 ${formatTime(kf.time)}`}
onClick={() => handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)}
>
×
</button>
</div>
);
})}
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeKeyframe(track.objectId, kf.id);
}}
>
×
</button>
</div>
);
})
)}
</div>
</div>
);
@@ -566,50 +1019,170 @@ export function TimelinePanel() {
{timeline.cameraTracks.map((track) => {
const shot = project.cameras.find((c) => c.id === track.cameraId);
const trackName = shot?.name ?? track.cameraId;
const trackKey = getTimelineTrackKey("camera", track.cameraId);
const collapsed = timelineCollapsedTracks.includes(trackKey);
const dragProps = getTrackDragProps(trackKey);
return (
<div className="timeline-track timeline-track-camera" key={track.cameraId}>
<div className="timeline-track-label" title={trackName}>
<div
className={`timeline-track timeline-track-camera${collapsed ? " is-collapsed" : ""}${
draggingTrackKey === trackKey ? " is-dragging" : ""
}`}
key={track.cameraId}
data-track-key={trackKey}
style={{ order: getTrackOrder(trackKey) }}
{...dragProps}
>
<div className="timeline-track-label" title={`${trackName} · 拖动整行可调整轨道顺序`}>
{renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
<Video aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">{trackName}</span>
<span className="timeline-track-count">{track.keyframes.length}</span>
</div>
<div className="timeline-track-lane">
{track.keyframes.map((kf) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "camera" &&
selectedKeyframe.targetId === track.cameraId &&
selectedKeyframe.keyframeId === kf.id;
return (
<div
key={kf.id}
className={`timeline-keyframe timeline-keyframe-camera${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除运镜关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeCameraKeyframe(track.cameraId, kf.id);
}}
{collapsed ? (
<span className="timeline-track-summary">{track.keyframes.length} 个运镜关键帧</span>
) : (
track.keyframes.map((kf) => {
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
const isSelected =
selectedKeyframe?.kind === "camera" &&
selectedKeyframe.targetId === track.cameraId &&
selectedKeyframe.keyframeId === kf.id;
return (
<div
key={kf.id}
className={`timeline-keyframe timeline-keyframe-camera${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
>
×
</button>
</div>
);
})}
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除运镜关键帧 ${formatTime(kf.time)}`}
onClick={(event) => {
event.stopPropagation();
removeCameraKeyframe(track.cameraId, kf.id);
}}
>
×
</button>
</div>
);
})
)}
</div>
</div>
);
})}
{characterObjects.map((character) => {
const trajectoryPoints = character.trajectoryPoints ?? [];
const motion = character.characterRig?.motion;
const motionLabel = MOTION_LABELS[motion?.kind ?? "none"];
const motionDisabled = motion?.enabled === false;
const motionTitle = motionDisabled
? `动作轨道:${motionLabel}(当前已关闭)`
: `动作轨道:${motionLabel}`;
const trackKey = getTimelineTrackKey("character", character.id);
const collapsed = timelineCollapsedTracks.includes(trackKey);
const dragProps = getTrackDragProps(trackKey);
return (
<div
className={`timeline-character-track${collapsed ? " is-collapsed" : ""}${
draggingTrackKey === trackKey ? " is-dragging" : ""
}`}
key={`character-track-${character.id}`}
data-track-key={trackKey}
style={{ order: getTrackOrder(trackKey) }}
{...dragProps}
>
{/* 分组抬头:折叠开关 + 角色名 + 轨迹点数(整组可拖动排序) */}
<div className="timeline-track timeline-character-head">
<div className="timeline-track-label" title={`${character.name} · 拖动整行可调整轨道顺序`}>
{renderCollapseToggle(trackKey, character.name, "角色轨道", collapsed)}
<PersonStanding aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">{character.name}</span>
<span className="timeline-track-count">{trajectoryPoints.length}</span>
</div>
{/* 折叠后抬头行里给一句摘要,避免整条轨道变成空白 */}
<div className="timeline-track-lane timeline-character-head-lane">
{collapsed ? (
<span className="timeline-track-summary">
{motionLabel} · {trajectoryPoints.length} 个轨迹点
</span>
) : null}
</div>
</div>
{collapsed ? null : (
<>
{/* 动作轨道:角色当前的基础动作(待机 / 行走 / 跑步) */}
<div className="timeline-subtrack" title={motionTitle}>
<div className="timeline-subtrack-label">动作</div>
<div className="timeline-track-lane timeline-motion-lane">
<span className={`timeline-motion-chip${motionDisabled ? " is-disabled" : ""}`}>
{motionLabel}
{motionDisabled ? "(已关闭)" : ""}
</span>
</div>
</div>
{/* 人物轨迹:轨迹点即关键帧,0s 从角色起始位置出发,末点落在时间轴结束时刻 */}
<div
className="timeline-subtrack"
title="人物轨迹:轨迹点即关键帧,0s 从角色起始位置出发,最后一个轨迹点落在时间轴结束时刻"
>
<div className="timeline-subtrack-label">轨迹</div>
<div className="timeline-track-lane">
{trajectoryPoints.map((point, index) => {
const pointTime = getTrajectoryPointTime(index, trajectoryPoints.length, duration);
const leftPercent = duration === 0 ? 0 : (pointTime / duration) * 100;
const isSelected =
selectedTrajectoryPoint?.objectId === character.id &&
selectedTrajectoryPoint.pointId === point.id;
return (
<div
key={point.id}
className={`timeline-keyframe timeline-keyframe-trajectory${isSelected ? " is-selected" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`轨迹关键帧 ${formatTime(pointTime)}`}
title={`第 ${index + 1} 个轨迹点 · ${formatTime(pointTime)}`}
onClick={() => handleSelectTrajectoryKeyframe(character.id, point.id, pointTime)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
<button
type="button"
className="timeline-keyframe-delete"
aria-label={`删除轨迹关键帧 ${formatTime(pointTime)}`}
onClick={(event) => {
event.stopPropagation();
removeTrajectoryPoint(character.id, point.id);
}}
>
×
</button>
</div>
);
})}
</div>
</div>
</>
)}
</div>
);
})}
{/* 拖拽落点线:绝对定位在轨道区上,靠 transform 移动(详见 showDropIndicator) */}
<div className="timeline-drop-indicator" ref={dropIndicatorRef} aria-hidden="true" />
</div>
<div className="timeline-playhead-layer" aria-hidden="true">