feat(timeline): 支持角色轨迹关键帧与轨道拖拽排序
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user