Files
ttstd-3d-director/src/editor/panels/TimelinePanel.tsx

2685 lines
124 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type {
CSSProperties,
ChangeEvent,
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
} from "react";
import {
Box,
Camera,
ChevronDown,
ChevronRight,
ChevronUp,
DiamondPlus,
Eraser,
Film,
MoveHorizontal,
Pause,
PersonStanding,
Play,
Plus,
RefreshCw,
Repeat,
Settings2,
SkipBack,
SkipForward,
Square,
Trash2,
Video,
X,
type LucideIcon,
} from "lucide-react";
import {
CAMERA_KEYFRAME_MIN_GAP,
DEFAULT_TIMELINE_FPS,
MAX_TIMELINE_DURATION,
MIN_TIMELINE_DURATION,
TIMELINE_DURATION_STEP,
TIMELINE_FPS_TIERS,
MIN_CAMERA_TRANSITION_DURATION,
MAX_CAMERA_TRANSITION_DURATION,
DEFAULT_CAMERA_TRANSITION_DURATION,
normalizeCameraTransitionDuration,
getCameraClipSpan,
moveCameraKeyframeTime,
normalizeTimelineDuration,
normalizeTimelineFps,
scaleCameraKeyframeStart,
scaleCameraKeyframeTimes,
EASING_KINDS,
EASING_LABELS,
getCameraCutSegments,
type CameraKeyframeValue,
type CameraTransition,
type CameraTransitionType,
type EasingKind,
type KeyframeTransformValue,
} from "../schema/timeline";
import type { DirectorCameraShot, MotionClip, MotionKind, ExportQuality, ExportCodecPreference } from "../schema/directorProject";
import { EXPORT_RESOLUTION_OPTIONS, EXPORT_QUALITY_OPTIONS, EXPORT_CODEC_OPTIONS } from "../schema/directorProject";
import {
TRAJECTORY_POINT_MIN_GAP,
getMotionEndTime,
getMotionStartTime,
getTrajectoryClipEndTime,
getTrajectoryPointTimes,
getTrajectoryStartTime,
moveTrajectoryPointTime,
scaleTrajectoryPointTimes,
} from "../schema/trajectoryMotion";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { exportVideo, type VideoExportView } from "../io/videoExport";
import { InspectorDropdown } from "../components/InspectorDropdown";
interface SelectedKeyframe {
kind: "object" | "camera";
targetId: string;
keyframeId: string;
}
/**
* 时间滑块手势的数据主人:
* - trajectory:人物轨迹(拖单个点走 updateTrajectoryPoint,按点 id 定位)
* - camera:运镜片段(拖把手 / 拖关键帧都整条覆盖时间,按索引对应)
*/
type TimelineTimeDragOwner =
| { kind: "trajectory"; objectId: string; pointId: string }
| { kind: "camera"; cameraId: string }
| { kind: "motion"; objectId: string }
| { kind: "motionClip"; objectId: string; clipId: string };
/** 时间轴片段(人物轨迹条 / 运镜片段条 / 动作片段条)的稳定标识 */
function getTimelineClipKey(kind: "trajectory" | "camera" | "motion" | "motionClip", id: string) {
return `${kind}:${id}`;
}
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;
/** 把秒换算成帧号(四舍五入到最近的一帧);帧率来自工程设置,默认 30fps */
export function getTimelineFrame(time: number, fps: number = DEFAULT_TIMELINE_FPS): number {
const safeTime = Number.isFinite(time) ? Math.max(0, time) : 0;
const safeFps = Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_TIMELINE_FPS;
return Math.round(safeTime * safeFps);
}
/** 拖动总时长时的灵敏度:横向 25px = 1s,拖起来不会太窜 */
export const TIMELINE_DURATION_DRAG_PIXELS_PER_SECOND = 25;
/**
* 把秒数对齐到 0.1s 刻度。
* 顺带消掉浮点尾巴:5 + 0.1 会得到 5.0999999999999996,直接塞进 number 输入框会显示出这一长串小数。
*/
export function alignTimelineDuration(seconds: number): number {
return Number(seconds.toFixed(2));
}
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 step = getTimelineRulerStep(safeDuration);
const ticks: TimelineRulerTick[] = [];
for (let value = 0; value <= safeDuration + 1e-6; value += step) {
const rounded = Number(value.toFixed(4));
ticks.push({ value: rounded, percent: (rounded / safeDuration) * 100 });
}
const lastTick = ticks[ticks.length - 1];
// 时长不是步长整数倍时补上末尾刻度,保证最右侧一定有刻度线
if (lastTick && lastTick.value < safeDuration) {
ticks.push({ value: safeDuration, percent: 100 });
}
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`;
}
/** 轨道左侧标签列的类型图标:和「对象树 / 添加角色」保持一致的语义 */
const OBJECT_KIND_ICONS: Record<string, LucideIcon> = {
character: PersonStanding,
camera: Video,
prop: Box,
};
/** 人物轨道「动作」子轨道里展示的基础动作名 */
const MOTION_LABELS: Record<MotionKind, string> = {
none: "无动作",
idle: "待机",
walk: "行走",
run: "跑步",
};
type TimelineTrackKind = "object" | "camera" | "character";
/** 轨道拖拽的启动阈值(px):小于它当作普通点击,避免抢走关键帧的选中点击 */
const TRACK_DRAG_THRESHOLD_PX = 4;
/** 时间滑块(片段 / 关键帧)吸附阈值(px):拖动的边缘进入其它片段边缘 / 播放头这么近就吸上去 */
const SNAP_THRESHOLD_PX = 8;
/** 轨道行的纵向几何(拖拽开始时量一次就缓存,移动中只查表) */
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);
const addKeyframe = useDirectorStore((state) => state.addKeyframe);
const removeKeyframe = useDirectorStore((state) => state.removeKeyframe);
const updateKeyframeValue = useDirectorStore((state) => state.updateKeyframeValue);
const applyKeyframeToObjectTransform = useDirectorStore((state) => state.applyKeyframeToObjectTransform);
const addCameraKeyframe = useDirectorStore((state) => state.addCameraKeyframe);
const removeCameraKeyframe = useDirectorStore((state) => state.removeCameraKeyframe);
const updateCameraKeyframeValue = useDirectorStore((state) => state.updateCameraKeyframeValue);
const updateKeyframeEasing = useDirectorStore((state) => state.updateKeyframeEasing);
const updateExportSettings = useDirectorStore((state) => state.updateExportSettings);
const updateCameraKeyframeEasing = useDirectorStore((state) => state.updateCameraKeyframeEasing);
const setCameraKeyframeTransition = useDirectorStore((state) => state.setCameraKeyframeTransition);
const setCameraKeyframeTimes = useDirectorStore((state) => state.setCameraKeyframeTimes);
const applyCameraKeyframeToShot = useDirectorStore((state) => state.applyCameraKeyframeToShot);
const setActiveCamera = useDirectorStore((state) => state.setActiveCamera);
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 updateTrajectoryPoint = useDirectorStore((state) => state.updateTrajectoryPoint);
const setTrajectoryPointTimes = useDirectorStore((state) => state.setTrajectoryPointTimes);
const setTrajectoryClipStart = useDirectorStore((state) => state.setTrajectoryClipStart);
const setTrajectoryClipTrimStart = useDirectorStore((state) => state.setTrajectoryClipTrimStart);
const setTrajectoryClipEnd = useDirectorStore((state) => state.setTrajectoryClipEnd);
const setMotionClipStart = useDirectorStore((state) => state.setMotionClipStart);
const setMotionClipTrimStart = useDirectorStore((state) => state.setMotionClipTrimStart);
const setMotionClipEnd = useDirectorStore((state) => state.setMotionClipEnd);
const addMotionClip = useDirectorStore((state) => state.addMotionClip);
const updateMotionClip = useDirectorStore((state) => state.updateMotionClip);
const updateMotionClipTime = useDirectorStore((state) => state.updateMotionClipTime);
const removeMotionClip = useDirectorStore((state) => state.removeMotionClip);
const selectMotionClip = useDirectorStore((state) => state.selectMotionClip);
const selectedMotionClipId = useDirectorStore((state) => state.selectedMotionClipId);
const selectObject = useDirectorStore((state) => state.selectObject);
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 setTimelinePlaybackSettings = useDirectorStore((state) => state.setTimelinePlaybackSettings);
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 setScrubbing = usePlaybackStore((state) => state.setScrubbing);
// 帧率 / 循环来自工程设置(旧工程在 store 迁移时补默认值)
const fps = project.timeline.fps ?? DEFAULT_TIMELINE_FPS;
const loop = project.timeline.loop === true;
// 时长编辑以工程里的值为准:playbackStore 那份是给播放用的副本,靠帧驱动同步、会慢一拍,
// 连续拖 / 连续点微调按钮时会把上一次的结果吞掉。
const editableDuration = project.timeline.duration ?? duration;
const [selectedKeyframe, setSelectedKeyframe] = useState<SelectedKeyframe | null>(null);
/** 选中的片段(轨迹条 / 运镜条):点条本体或条上的关键帧都会选中它 */
const [selectedClip, setSelectedClip] = useState<string | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
// 设置弹窗:fps / loop 先放草稿里改,点「完成」才写回工程(不点则原样丢弃)
const [settingsOpen, setSettingsOpen] = useState(false);
const [draftFps, setDraftFps] = useState(fps);
const [draftLoop, setDraftLoop] = useState(loop);
// 帧率档位:固定常用值;若工程里是档位外的帧率(历史数据),也一并列出避免被吞
const fpsOptions = Array.from(new Set([...TIMELINE_FPS_TIERS, draftFps])).sort((a, b) => a - b);
// 多机位切换过渡草稿(全局默认):类型 + 时长,点「完成」才写回工程
const [draftTransitionType, setDraftTransitionType] = useState<CameraTransitionType>("smooth");
const [draftTransitionDuration, setDraftTransitionDuration] = useState(DEFAULT_CAMERA_TRANSITION_DURATION);
// 导出设置草稿:分辨率 / 画质 / 自定义码率 / 编码档位,点「完成」才写回工程
const [draftResolution, setDraftResolution] = useState(720);
const [draftQuality, setDraftQuality] = useState<ExportQuality>("medium");
const [draftCustomBitrate, setDraftCustomBitrate] = useState(8_000_000);
const [draftCodec, setDraftCodec] = useState<ExportCodecPreference>("auto");
/** 拖动总时长:按下时记一次起始时长与指针位置,移动中只做线性换算 */
const durationDragRef = useRef<{ pointerId: number; startClientX: number; startDuration: number } | null>(null);
// 轨道拖拽:生命周期(谁在拖 → 高亮 / 毛玻璃降级)走 React state,一次拖拽只重渲染两次;
// 高频的落点线则完全不经过 React(ref + transform),详见 getTrackDragProps。
const [draggingTrackKey, setDraggingTrackKey] = useState<string | null>(null);
/** 当前在「机位切换」概览轨中选中的过渡(按切点关键帧 id 定位),用于弹出编辑器 */
const [selectedCutKeyframeId, setSelectedCutKeyframeId] = useState<string | null>(null);
useEffect(() => {
if (!selectedCutKeyframeId) return;
function handlePointerDown(event: MouseEvent) {
const target = event.target as HTMLElement;
if (target.closest(".timeline-transition-editor")) return;
if (target.closest(".timeline-transition-marker")) return;
setSelectedCutKeyframeId(null);
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, [selectedCutKeyframeId]);
const dropTargetRef = useRef<{ key: string; position: TimelineTrackDropPosition } | null>(null);
const dropIndicatorRef = useRef<HTMLDivElement | null>(null);
/** 吸附参考线:拖动片段 / 关键帧时,边缘吸到其它片段边缘或播放头处显示一条高亮竖线 */
const snapIndicatorRef = useRef<HTMLDivElement | null>(null);
/** 当前这一次指针手势(按下 → 超过阈值才算真的开始拖拽,否则当普通点击放行) */
const dragGestureRef = useRef<{
pointerId: number;
sourceKey: string;
active: boolean;
startX: number;
startY: number;
} | null>(null);
/**
* 正在拖时间滑块(人物轨迹条 / 运镜片段条共用):
* - point:拖单个关键帧,只改这一帧的时间
* - duration:拖条右端把手,整段时长按比例缩放
* - clip:拖条本体,整段左右平移(可以脱离 0s 往后放)
* - trimStart:拖条左端把手,只挪片段起点(各轨迹点原地不动)
*/
const timeDragRef = useRef<{
pointerId: number;
owner: TimelineTimeDragOwner;
kind: "point" | "duration" | "clip" | "trimStart";
index: number;
startTimes: number[];
/** 片段左端在按下时的位置(平移基准) */
startEdge: number;
/** 片段右端在按下时的位置(轨迹最后一点停留结束,作为拖关键帧的上界) */
endEdge: number;
startClientX: number;
secondsPerPixel: number;
/** 拖拽开始时该角色的全部动作片段(用于「拖动换位」:依据原始布局做确定性重排) */
startClips?: MotionClip[];
} | 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);
// 高度由内容决定(不再手动拖拽):把实测高度发布成 CSS 变量,
// 让视口快捷工具条、画幅浮层和左右悬浮卡片自动让出时间轴占用的空间。
useEffect(() => {
const element = panelRef.current;
if (!element) return;
const root = document.documentElement;
const publishHeight = () => {
const height = element.getBoundingClientRect().height;
if (height > 0) root.style.setProperty("--timeline-panel-height", `${Math.round(height)}px`);
};
publishHeight();
if (typeof ResizeObserver === "undefined") {
window.addEventListener("resize", publishHeight);
return () => {
window.removeEventListener("resize", publishHeight);
root.style.removeProperty("--timeline-panel-height");
};
}
const resizeObserver = new ResizeObserver(publishHeight);
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
root.style.removeProperty("--timeline-panel-height");
};
}, []);
useEffect(() => {
if (!exportMenuOpen) return;
function closeExportMenuOnOutsidePointerDown(event: PointerEvent) {
if (event.target instanceof Node && exportMenuRef.current?.contains(event.target)) return;
setExportMenuOpen(false);
}
function closeExportMenuOnEscape(event: KeyboardEvent) {
if (event.key === "Escape") setExportMenuOpen(false);
}
document.addEventListener("pointerdown", closeExportMenuOnOutsidePointerDown);
document.addEventListener("keydown", closeExportMenuOnEscape);
return () => {
document.removeEventListener("pointerdown", closeExportMenuOnOutsidePointerDown);
document.removeEventListener("keydown", closeExportMenuOnEscape);
};
}, [exportMenuOpen]);
// 轨迹关键帧没有独立的删除按钮,选中态(.is-selected)就是「按 Delete 会删掉它」的唯一提示,
// 所以点到别处(轨道空白、别的轨道、面板其它区域,甚至时间轴之外)就必须取消选中并移走焦点,
// 否则选中高亮和焦点框会一直留在那颗关键帧上,看起来像还能按 Delete 删它。
useEffect(() => {
if (!selectedTrajectoryPoint) return;
function clearTrajectoryPointOnOutsidePointerDown(event: PointerEvent) {
const target = event.target instanceof Element ? event.target : null;
// 点在轨迹关键帧自己身上不算「别处」——那一下是选中它 / 拖它改时间
if (target?.closest(".timeline-keyframe-trajectory")) return;
// 右侧「轨迹点」属性面板编辑的就是这个点,点它(滑块 / 数值框 / 标题栏)也不算别处,
// 否则每改一次位置或角度,选中态就被清掉、面板跳回角色属性卡片。
if (target?.closest(".trajectory-point-inspector")) return;
selectTrajectoryPoint(null, null);
// 焦点还停在那颗关键帧上时一并移走,避免焦点框留在一个已经没选中的元素上
const active = document.activeElement;
if (active instanceof HTMLElement && active.classList.contains("timeline-keyframe-trajectory")) {
active.blur();
}
}
document.addEventListener("pointerdown", clearTrajectoryPointOnOutsidePointerDown);
return () => document.removeEventListener("pointerdown", clearTrajectoryPointOnOutsidePointerDown);
}, [selectedTrajectoryPoint, selectTrajectoryPoint]);
// 设置弹窗:Esc 直接关掉(等同取消,草稿值丢弃)
useEffect(() => {
if (!settingsOpen) return;
function closeSettingsOnEscape(event: KeyboardEvent) {
if (event.key === "Escape") setSettingsOpen(false);
}
document.addEventListener("keydown", closeSettingsOnEscape);
return () => document.removeEventListener("keydown", closeSettingsOnEscape);
}, [settingsOpen]);
const timeline = project.timeline;
// 选中目标解析:物体 / 相机
const selectedObject = project.objects.find((o) => o.id === selectedObjectId) ?? null;
const activeCamera =
project.activeCameraId
? project.cameras.find((c) => c.id === project.activeCameraId) ?? null
: null;
// 打关键帧时:选中非相机物体 -> 给该物体打位姿关键帧;
// 否则(选中相机物体 / 没选中任何物体)-> 给「当前机位(activeCameraId)」打运镜关键帧。
const addTargetsCamera = Boolean(activeCamera) && (!selectedObject || selectedObject.kind === "camera");
const canAddCameraKeyframe = Boolean(activeCamera && activeCamera.targetMode === "manual");
function snapshotObjectTransform(): KeyframeTransformValue {
if (!selectedObject) throw new Error("no selected object");
return {
position: [...selectedObject.transform.position] as [number, number, number],
rotation: [...selectedObject.transform.rotation] as [number, number, number],
scale: [...selectedObject.transform.scale] as [number, number, number],
};
}
function handleAddKeyframe() {
if (addTargetsCamera) {
if (!activeCamera || activeCamera.targetMode !== "manual") return;
// 直接按「当前机位」打关键帧:运镜关键帧始终属于 activeCameraId,
// 与选中了哪个物体无关,这样多个机位都能各自打关键帧
// (旧逻辑只认「选中物体关联的机位」,第二个机位往往因为没选中其物体而打不上)。
addCameraKeyframe(activeCamera.id, playhead, {
position: [...activeCamera.transform.position] as [number, number, number],
lookRotation: [...activeCamera.lookRotation] as [number, number, number],
fov: activeCamera.fov,
});
} else if (selectedObject && selectedObject.kind !== "character") {
// 角色由轨迹(trajectory)驱动,不占 transform 关键帧轨道,
// 否则同一个角色会同时出现「物体轨道」和「角色轨道」两条。
addKeyframe(selectedObject.id, playhead, snapshotObjectTransform());
}
}
function handleSelectObjectKeyframe(objectId: string, keyframeId: string, time: number, value: KeyframeTransformValue) {
setPlaying(false);
setPlayhead(time);
applyKeyframeToObjectTransform(objectId, value);
setSelectedKeyframe({ kind: "object", targetId: objectId, keyframeId });
}
function handleSelectCameraKeyframe(cameraId: string, keyframeId: string, time: number, value: CameraKeyframeValue) {
setPlaying(false);
setPlayhead(time);
applyCameraKeyframeToShot(cameraId, value);
// 把该机位设为当前编辑机位:机位面板(CameraPanel)始终按 activeCameraId 编辑,
// 否则在「机位视角」或当前激活机位与选中物体不一致时,编辑的是别的机位,改动写不回这个关键帧。
setActiveCamera(cameraId);
setSelectedKeyframe({ kind: "camera", targetId: cameraId, keyframeId });
}
function handleUpdateSelected() {
if (!selectedKeyframe) return;
if (selectedKeyframe.kind === "object" && selectedObject) {
updateKeyframeValue(selectedKeyframe.targetId, selectedKeyframe.keyframeId, snapshotObjectTransform());
} else if (selectedKeyframe.kind === "camera") {
// 直接读「这个关键帧所属机位」的当前机位值写回,而不是 selectedShot
// (selectedShot 是「选中物体关联的机位」,在机位视角 / 激活机位与选中物体不一致时会对错机位,导致改了角度却写不回关键帧)。
const camera = project.cameras.find((c) => c.id === selectedKeyframe.targetId);
if (camera) {
updateCameraKeyframeValue(selectedKeyframe.targetId, selectedKeyframe.keyframeId, {
position: [...camera.transform.position] as [number, number, number],
lookRotation: [...camera.lookRotation] as [number, number, number],
fov: camera.fov,
});
}
}
}
function handleDeleteSelected() {
if (selectedTrajectoryPoint) {
removeTrajectoryPoint(selectedTrajectoryPoint.objectId, selectedTrajectoryPoint.pointId);
return;
}
if (!selectedKeyframe) return;
if (selectedKeyframe.kind === "object") {
removeKeyframe(selectedKeyframe.targetId, selectedKeyframe.keyframeId);
} else {
removeCameraKeyframe(selectedKeyframe.targetId, selectedKeyframe.keyframeId);
}
setSelectedKeyframe(null);
}
/** 选中关键帧当前使用的缓动曲线(用于下拉框的选中项) */
const selectedKeyframeEasing: EasingKind = useMemo(() => {
if (!selectedKeyframe) return "linear";
const findEasing = (track: { keyframes: { id: string; easing?: EasingKind }[] } | undefined) =>
track?.keyframes.find((k) => k.id === selectedKeyframe.keyframeId)?.easing ?? "linear";
if (selectedKeyframe.kind === "object") {
return findEasing(project.timeline.tracks.find((t) => t.objectId === selectedKeyframe.targetId));
}
return findEasing(project.timeline.cameraTracks.find((t) => t.cameraId === selectedKeyframe.targetId));
}, [selectedKeyframe, project.timeline]);
function handleEasingChange(event: ChangeEvent<HTMLSelectElement>) {
if (!selectedKeyframe) return;
const easing = event.target.value as EasingKind;
if (selectedKeyframe.kind === "object") {
updateKeyframeEasing(selectedKeyframe.targetId, selectedKeyframe.keyframeId, easing);
} else {
updateCameraKeyframeEasing(selectedKeyframe.targetId, selectedKeyframe.keyframeId, easing);
}
}
/** 人物轨迹上的「轨迹关键帧」:轨迹点本身就是关键帧,点它即选中对应轨迹点并把指针定位到该点时间 */
function handleSelectTrajectoryKeyframe(objectId: string, pointId: string, time: number) {
setPlaying(false);
setPlayhead(time);
selectTrajectoryPoint(objectId, pointId);
}
/** 总时长微调(上下按钮 / 键盘左右键):按 0.1s 刻度走,统一交给 store 夹取 */
function nudgeDuration(offsetSeconds: number) {
setTimelineDuration(normalizeTimelineDuration(alignTimelineDuration(editableDuration + offsetSeconds)));
}
/**
* 总时长拖动把手(「总时长」左边的双向箭头):左右拖动减 / 加总时长。
* 灵敏度固定(25px = 1s)并对齐到 0.1s,避免拖一下跳一大截。
*/
function handleDurationDragMove(event: ReactPointerEvent<HTMLElement>) {
const drag = durationDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.preventDefault();
const deltaSeconds = (event.clientX - drag.startClientX) / TIMELINE_DURATION_DRAG_PIXELS_PER_SECOND;
setTimelineDuration(normalizeTimelineDuration(alignTimelineDuration(drag.startDuration + deltaSeconds)));
}
function handleDurationDragEnd(event: ReactPointerEvent<HTMLElement>) {
const drag = durationDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
durationDragRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// jsdom 等环境不支持 pointer capture,忽略后仍能继续拖动
}
}
function getDurationDragProps() {
return {
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0) return;
// 这次手势归总时长把手,不要被下面的轨道拖拽抢走
event.stopPropagation();
durationDragRef.current = {
pointerId: event.pointerId,
startClientX: event.clientX,
startDuration: editableDuration,
};
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// 同上
}
},
onPointerMove: handleDurationDragMove,
onPointerUp: handleDurationDragEnd,
onPointerCancel: handleDurationDragEnd,
};
}
function openSettings() {
// 每次打开都从当前工程值重新生成草稿,避免上次没点「完成」的残留值
setDraftFps(fps);
setDraftLoop(loop);
setDraftTransitionType(project.timeline.cameraTransition.type);
setDraftTransitionDuration(project.timeline.cameraTransition.duration);
setDraftResolution(project.exportSettings.resolutionShortEdge);
setDraftQuality(project.exportSettings.quality);
setDraftCustomBitrate(project.exportSettings.customBitrate);
setDraftCodec(project.exportSettings.codec);
setSettingsOpen(true);
}
/** 「完成」才保存并应用:帧率 / 循环 / 过渡 / 导出设置一次性写回工程,弹窗随之关闭 */
function confirmSettings() {
setTimelinePlaybackSettings({
fps: normalizeTimelineFps(draftFps),
loop: draftLoop,
cameraTransition: {
type: draftTransitionType,
duration: normalizeCameraTransitionDuration(draftTransitionDuration),
},
});
updateExportSettings({
resolutionShortEdge: draftResolution,
quality: draftQuality,
customBitrate: draftCustomBitrate,
codec: draftCodec,
});
setSettingsOpen(false);
}
function handleClearAll() {
if (timeline.tracks.length === 0 && timeline.cameraTracks.length === 0) return;
if (window.confirm("确定清空所有关键帧吗?此操作可用撤销恢复。")) {
clearAllKeyframes();
setSelectedKeyframe(null);
}
}
async function handleExportVideo(view: VideoExportView) {
setIsRecording(true);
try {
await exportVideo(view);
} catch (error) {
console.error("[videoExport]", error);
window.alert(error instanceof Error ? error.message : "视频导出失败");
} finally {
setIsRecording(false);
}
}
// 刻度尺本身就是时间指针:点击 / 拖动定位,方向键 / Home / End 微调
const rulerRef = useRef<HTMLDivElement | null>(null);
const rulerRectRef = useRef<DOMRect | null>(null);
const [isScrubbing, setIsScrubbing] = useState(false);
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, fps);
const totalFrames = getTimelineFrame(duration, fps);
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 顺序保持稳定(不影响既有查询与无障碍阅读顺序)。
// 角色由轨迹驱动,只走「角色轨道」;其 transform 关键帧不单独占一条「物体轨道」,
// 否则同一个角色会在时间轴上出现两条。
const nonCharacterObjectIds = new Set(
project.objects.filter((item) => item.kind !== "character").map((item) => item.id)
);
const defaultTrackKeys = [
...timeline.tracks
.filter((track) => nonCharacterObjectIds.has(track.objectId))
.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,
};
}
/**
* 点轨道空白处 = 取消所有已选中的片段 / 关键帧 / 轨迹点 / 动作片段。
* 片段 / 关键帧 / 各类手柄自己的手势里会 stopPropagation,正常情况冒泡不到这里;
* 但手势可能因为尺寸未就绪等原因提前返回(那时还没走到 stopPropagation),
* 所以这里再按事件来源兜一层,避免把刚选中的条目立刻清掉。
*/
function handleTimelineGridPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
// 点在交互元素(片段条 / 关键帧 / 拖拽把手 / 删除按钮 / 添加按钮 / 折叠开关)上不算空白处,
// 那些手势自己会处理选中,这里不能清掉它们。
if (
event.target instanceof Element &&
event.target.closest(
".timeline-trajectory-bar, .timeline-keyframe, .timeline-trajectory-trim, .timeline-clip-delete, .timeline-add-clip-button, .timeline-track-icon-button"
)
) {
return;
}
// 点轨道空白处:取消所有已选中项,让右侧编辑面板回到未选中态。
setSelectedClip(null);
setSelectedKeyframe(null);
selectTrajectoryPoint(null, null);
selectMotionClip(null);
}
/**
* 片段是否处于选中态。只有一个来源:`selectedClip` ——
* 任何一次片段手势(拖条本体 / 拖把手 / 拖条上的关键帧)都会把这条片段标成选中,
* 点轨道空白处取消。不跟关键帧 / 轨迹点选中做联动,是为了让「点空白处取消选中」始终有效。
*/
function isClipSelected(kind: "trajectory" | "camera" | "motion" | "motionClip", id: string) {
return selectedClip === getTimelineClipKey(kind, id);
}
/** 拖整段时长(条右端把手 / 键盘微调):
* - 运镜:末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定;
* - 人物轨迹:末点不再等于时间结束位置,这条把手改的是「停留时长」(角色走完最后一点后
* 原地停留到这一刻),所以只写 trajectoryEndTime,不动各轨迹点的时间。 */
function scaleClipDuration(
owner: TimelineTimeDragOwner,
times: number[],
startEdge: number,
deltaSeconds: number
) {
const targetEnd =
(owner.kind === "trajectory" ? startEdge : times[times.length - 1] ?? startEdge) + deltaSeconds;
if (owner.kind === "trajectory") {
setTrajectoryClipEnd(owner.objectId, targetEnd);
return;
}
if (owner.kind === "motion") {
setMotionClipEnd(owner.objectId, targetEnd);
return;
}
if (owner.kind === "motionClip") {
updateMotionClipTime(owner.objectId, owner.clipId, times[0], targetEnd, times[0]);
return;
}
setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeTimes(times, targetEnd, duration));
}
/** 拖单个关键帧:只改它自己的时间(被前后邻居夹住;轨迹最后一点还受停留结束时间约束) */
function moveClipKeyframe(
owner: TimelineTimeDragOwner,
times: number[],
index: number,
startEdge: number,
deltaSeconds: number,
endEdge = duration
) {
// 动作片段没有「单个关键帧」可拖(只有起点/终点两端,分别由 trimStart / duration 把手负责)
if (owner.kind === "motion" || owner.kind === "motionClip") return;
const targetTime = (times[index] ?? 0) + deltaSeconds;
if (owner.kind === "trajectory") {
const nextTimes = moveTrajectoryPointTime(times, index, targetTime, duration, startEdge, endEdge);
updateTrajectoryPoint(owner.objectId, owner.pointId, { time: nextTimes[index] });
return;
}
setCameraKeyframeTimes(owner.cameraId, moveCameraKeyframeTime(times, index, targetTime, duration));
}
/**
* 拖条本体:整段片段左右平移,可以脱离 0s 往后放(前面那段时间角色原地等待 / 相机保持首帧机位)。
* 先把位移夹住(片段两端都不越出时间轴),再整体平移 —— 片段刚性移动,各关键帧的节奏不变。
*/
function shiftClip(
owner: TimelineTimeDragOwner,
times: number[],
startEdge: number,
deltaSeconds: number,
originalClips?: MotionClip[]
) {
const endEdge = times.length > 0 ? times[times.length - 1] : startEdge;
const minShift = -startEdge;
const maxShift = Math.max(duration - endEdge, minShift);
const shift = Math.min(Math.max(deltaSeconds, minShift), maxShift);
// 顶到头(例如片段已经占满整条时间轴)时什么都不写,避免留下没有变化的一步撤销
if (shift === 0) return;
if (owner.kind === "trajectory") {
setTrajectoryClipStart(owner.objectId, startEdge + shift);
return;
}
if (owner.kind === "motion") {
setMotionClipStart(owner.objectId, startEdge + shift);
return;
}
if (owner.kind === "motionClip") {
updateMotionClipTime(
owner.objectId,
owner.clipId,
startEdge + shift,
times[times.length - 1] + shift,
times[0],
originalClips
);
return;
}
setCameraKeyframeTimes(owner.cameraId, times.map((time) => time + shift));
}
/**
* 修剪起点(条左端把手),和右端把手对称:
* - 人物轨迹:只挪片段起点 —— 角色在这之前原地等待,各轨迹点与停留结束时间留在原地,
* 所以「等待多久」变了,走位本身的节奏没变;
* - 运镜:末帧固定不动,首帧推到新位置,其余关键帧按比例缩放 —— 和右端把手(首帧固定、
* 末帧伸缩)正好反过来,整段运镜的节奏保持不变。
*/
function trimClipStart(
owner: TimelineTimeDragOwner,
times: number[],
startEdge: number,
deltaSeconds: number
) {
if (owner.kind === "camera") {
setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeStart(times, startEdge + deltaSeconds, duration));
return;
}
if (owner.kind === "motion") {
setMotionClipTrimStart(owner.objectId, startEdge + deltaSeconds);
return;
}
if (owner.kind === "motionClip") {
updateMotionClipTime(
owner.objectId,
owner.clipId,
startEdge + deltaSeconds,
times[1] ?? startEdge,
times[0]
);
return;
}
if (owner.kind !== "trajectory") return;
setTrajectoryClipTrimStart(owner.objectId, startEdge + deltaSeconds);
}
/**
* 时间滑块(人物轨迹条 / 运镜片段条同构):拖动关键帧改「这一帧的时间」,
* 拖动条右端把手改「整段时长」(其余帧按比例缩放)。像素 → 秒用轨道宽度换算,宽度只在按下时量一次。
*/
function beginTimeDrag(
event: ReactPointerEvent<HTMLElement>,
owner: TimelineTimeDragOwner,
kind: "point" | "duration" | "clip" | "trimStart",
index: number,
times: number[],
startEdge = 0,
endEdge = duration
) {
if (event.button !== 0) return;
if (isTrackDragBlockedTarget(event.target)) return;
// 任何一次片段手势(拖条本体 / 拖把手 / 拖关键帧)都先把这段片段标成选中
const clipOwnerId = owner.kind === "camera" ? owner.cameraId : owner.objectId;
setSelectedClip(getTimelineClipKey(owner.kind, clipOwnerId));
// 选中动作片段时同步到 store,供右侧「基础动作」卡片编辑对应的片段
if (owner.kind === "motionClip") selectMotionClip(owner.clipId);
const lane = event.currentTarget.closest(".timeline-track-lane");
const laneWidth = lane instanceof HTMLElement ? lane.getBoundingClientRect().width : 0;
if (laneWidth <= 0 || duration <= 0) return;
// 这次手势归时间滑块,轨道行的「拖动排序」不要来抢
event.stopPropagation();
timeDragRef.current = {
pointerId: event.pointerId,
owner,
kind,
index,
startTimes: [...times],
startEdge,
endEdge,
startClientX: event.clientX,
secondsPerPixel: duration / laneWidth,
// 拖动作片段本体时记录按下时刻的全部片段,供「拖动换位」使用
startClips:
owner.kind === "motionClip"
? (project.objects.find((o) => o.id === owner.objectId)?.characterRig?.motionClips ?? []).map((c) => ({ ...c }))
: undefined,
};
// 拖动时冻结全站毛玻璃:条 / 关键帧每次重绘都会让背后 WebGL 画布重算一次模糊
document.body.classList.add("is-timeline-dragging");
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// jsdom 等环境不支持 pointer capture,忽略后仍能继续拖动
}
}
function handleTimeDragMove(event: ReactPointerEvent<HTMLElement>) {
const drag = timeDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
const rawDelta = (event.clientX - drag.startClientX) * drag.secondsPerPixel;
// 吸附:把要移动的边缘吸到其它片段边缘 / 播放头(磁性对齐)
const { delta, snappedTime } = computeSnappedDelta(drag, rawDelta);
showSnapIndicator(snappedTime);
if (drag.kind === "duration") {
scaleClipDuration(drag.owner, drag.startTimes, drag.startEdge, delta);
return;
}
if (drag.kind === "clip") {
shiftClip(drag.owner, drag.startTimes, drag.startEdge, delta, drag.startClips);
return;
}
if (drag.kind === "trimStart") {
trimClipStart(drag.owner, drag.startTimes, drag.startEdge, delta);
return;
}
moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, delta, drag.endEdge);
}
function handleTimeDragEnd(event: ReactPointerEvent<HTMLElement>) {
const drag = timeDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
timeDragRef.current = null;
hideSnapIndicator();
document.body.classList.remove("is-timeline-dragging");
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// 同上,忽略不支持的环境
}
}
/** 把某个关键帧 / 时长把手 / 条本体接到同一套拖拽手势上 */
function getTimeDragProps(
owner: TimelineTimeDragOwner,
kind: "point" | "duration" | "clip" | "trimStart",
index: number,
times: number[],
startEdge = 0,
endEdge = duration
) {
return {
onPointerDown: (event: ReactPointerEvent<HTMLElement>) =>
beginTimeDrag(event, owner, kind, index, times, startEdge, endEdge),
onPointerMove: handleTimeDragMove,
onPointerUp: handleTimeDragEnd,
onPointerCancel: handleTimeDragEnd,
};
}
type TimeDragGesture = NonNullable<typeof timeDragRef.current>;
/**
* 收集拖拽时可吸附的目标时刻:同轨道(同角色 / 同相机)其它片段的边缘 + 播放头。
* 过滤掉被拖元素自身当前的边缘 / 关键帧时刻,避免「吸回原处」导致无法小幅移动。
*/
function buildSnapTargets(drag: TimeDragGesture): number[] {
const targets: number[] = [playhead];
const { owner } = drag;
if (owner.kind === "motionClip") {
const obj = project.objects.find((o) => o.id === owner.objectId);
for (const c of obj?.characterRig?.motionClips ?? []) {
if (c.id === owner.clipId) continue;
targets.push(c.start);
if (Number.isFinite(c.end)) targets.push(c.end);
}
} else if (owner.kind === "trajectory") {
const track = project.timeline.tracks.find((t) => t.objectId === owner.objectId);
for (const k of track?.keyframes ?? []) {
if (k.id === owner.pointId) continue;
targets.push(k.time);
}
} else if (owner.kind === "camera") {
const track = project.timeline.cameraTracks.find((t) => t.cameraId === owner.cameraId);
for (const k of track?.keyframes ?? []) targets.push(k.time);
} else if (owner.kind === "motion") {
const obj = project.objects.find((o) => o.id === owner.objectId);
if (obj) {
const start = getMotionStartTime(obj, duration);
const end = getMotionEndTime(obj, duration, start);
targets.push(start, end);
}
}
const selfTimes: number[] = [];
if (drag.kind === "clip") selfTimes.push(drag.startEdge, drag.endEdge);
else if (drag.kind === "trimStart") selfTimes.push(drag.startEdge);
else if (drag.kind === "duration") selfTimes.push(drag.endEdge);
else if (drag.kind === "point") {
const t = drag.startTimes[drag.index];
if (typeof t === "number") selfTimes.push(t);
}
return targets.filter((tt) => !selfTimes.some((s) => Math.abs(tt - s) < 1e-6));
}
/**
* 收集「网格类」吸附目标:整帧边界(k/fps)+ 刻度网格主刻度线(与车道里画出的竖网格线一致)。
* 这是一组「静态基准线」,和动态的同轨片段边缘 / 播放头互补:离后者近时优先吸到后者
* (保留原有磁性对齐,可对齐到非整帧位置),否则吸到最近的整帧 / 刻度线。
*/
function buildGridSnapTargets(): number[] {
const targets: number[] = [];
// 刻度网格:主刻度线(与车道背景的竖网格线一致),便于吸附对齐到可见网格
for (const tick of getTimelineRulerTicks(duration)) {
targets.push(tick.value);
}
// 整帧:每帧边界(k/fps),视频按帧导出,关键帧落在整帧上更自然
const frameCount = Math.floor(duration * fps + 1e-6);
for (let k = 0; k <= frameCount; k += 1) {
const tick = k / fps;
if (tick <= duration + 1e-6) targets.push(tick);
}
return targets;
}
/**
* 把候选时刻吸附到最近的目标(在阈值内),否则原样返回。
*/
function snapTimeToTargets(candidate: number, targets: number[], thresholdSec: number): number {
let best = candidate;
let bestDist = thresholdSec;
for (const t of targets) {
const d = Math.abs(candidate - t);
if (d < bestDist) {
bestDist = d;
best = t;
}
}
return best;
}
/**
* 根据拖拽手势与原始位移算出「吸附后的位移」以及被吸附到的时刻(未吸附则为 null)。
* 条本体 / 左端把手吸附起点;右端把手吸附终点;关键帧吸附其自身时刻。
*/
function computeSnappedDelta(drag: TimeDragGesture, rawDelta: number): { delta: number; snappedTime: number | null } {
const thresholdSec = SNAP_THRESHOLD_PX * drag.secondsPerPixel;
let candidate: number;
let base: number;
if (drag.kind === "clip" || drag.kind === "trimStart") {
candidate = drag.startEdge + rawDelta;
base = drag.startEdge;
} else if (drag.kind === "duration") {
candidate = drag.endEdge + rawDelta;
base = drag.endEdge;
} else {
const t = drag.startTimes[drag.index] ?? 0;
candidate = t + rawDelta;
base = t;
}
// 第一阶段:吸附到同轨其它片段边缘 / 播放头(保留原有磁性对齐,可对齐到非整帧位置)。
const primarySnapped = snapTimeToTargets(candidate, buildSnapTargets(drag), thresholdSec);
let snapped = primarySnapped;
let didSnap = Math.abs(snapped - candidate) > 1e-6;
// 第二阶段:未吸到片段边缘 / 播放头时,再吸附到整帧 / 刻度网格(对齐到可见网格与帧边界)。
if (!didSnap) {
const gridSnapped = snapTimeToTargets(candidate, buildGridSnapTargets(), thresholdSec);
if (Math.abs(gridSnapped - candidate) > 1e-6) {
snapped = gridSnapped;
didSnap = true;
}
}
return { delta: snapped - base, snappedTime: didSnap ? snapped : null };
}
/** 显示 / 隐藏吸附参考线(直接改样式,不触发 React 重绘) */
function showSnapIndicator(time: number | null) {
const el = snapIndicatorRef.current;
if (!el) return;
if (time === null || duration <= 0) {
el.classList.remove("is-visible");
return;
}
const percent = Math.min(100, Math.max(0, (time / duration) * 100));
el.style.left = `${percent}%`;
el.classList.add("is-visible");
}
function hideSnapIndicator() {
snapIndicatorRef.current?.classList.remove("is-visible");
}
/** 时长把手支持键盘微调(左右方向键 ±0.1s,按住 Shift ±0.5s) */
function handleTimeHandleKeyDown(
event: ReactKeyboardEvent<HTMLElement>,
owner: TimelineTimeDragOwner,
times: number[],
startEdge = 0
) {
const step = event.shiftKey ? 0.5 : 0.1;
const delta = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
if (delta === 0 || times.length === 0) return;
event.preventDefault();
scaleClipDuration(owner, times, startEdge, delta);
}
/** 修剪起点的把手同样支持键盘微调(左右方向键 ±0.1s,按住 Shift ±0.5s) */
function handleTrimStartKeyDown(
event: ReactKeyboardEvent<HTMLElement>,
owner: TimelineTimeDragOwner,
times: number[],
startEdge: number
) {
const step = event.shiftKey ? 0.5 : 0.1;
const delta = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
if (delta === 0) return;
event.preventDefault();
trimClipStart(owner, times, startEdge, delta);
}
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;
// 拖动播放头时每次 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));
return ratio * duration;
}
/** 手动定位一律先暂停播放,避免播放循环把指针立刻推走 */
function seekTo(time: number) {
setPlaying(false);
setPlayhead(time);
}
function handleRulerPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
// 每次开始拖动重新量一次刻度尺(窗口可能刚被缩放)
rulerRectRef.current = null;
const time = getTimeFromPointer(event.clientX);
if (time === null) return;
event.preventDefault();
setIsScrubbing(true);
setScrubbing(true);
seekTo(time);
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// jsdom 等环境不支持 pointer capture,忽略后仍可通过 move/up 拖动
}
}
function handleRulerPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
if (!isScrubbing) return;
const time = getTimeFromPointer(event.clientX);
if (time !== null) setPlayhead(time);
}
function handleRulerPointerEnd(event: ReactPointerEvent<HTMLDivElement>) {
if (!isScrubbing) return;
setIsScrubbing(false);
setScrubbing(false);
rulerRectRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// 同上,忽略不支持的环境
}
}
function handleRulerKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
const step = duration <= 10 ? 0.1 : 0.5;
const seekActions: Record<string, () => void> = {
ArrowLeft: () => seekTo(playhead - step),
ArrowRight: () => seekTo(playhead + step),
Home: () => seekTo(0),
End: () => seekTo(duration),
};
const action = seekActions[event.key];
if (!action) return;
event.preventDefault();
action();
}
// 焦点在时间轴上时:
// - 空格键切换播放 / 暂停(正在播放则暂停,暂停或停止则播放);
// - Delete / Backspace 删除选中的轨迹点(角色轨迹点没有独立删除按钮,选中后按删除键移除)。
// 焦点落在原生可激活控件(按钮 / 输入框等)上时交给控件自身处理,避免与它们的行为重复触发。
function handlePanelKeyDown(event: ReactKeyboardEvent<HTMLElement>) {
const target = event.target as HTMLElement | null;
const insideNativeControl = !!(
target && target.closest("button, input, textarea, select, [contenteditable='true']")
);
if (event.key === "Delete" || event.key === "Backspace") {
if (insideNativeControl) return;
if (selectedTrajectoryPoint) {
event.preventDefault();
handleDeleteSelected();
}
return;
}
if (event.key !== " " && event.key !== "Spacebar") return;
if (insideNativeControl) return;
event.preventDefault();
togglePlaying();
}
const addKeyframeDisabled = addTargetsCamera ? !canAddCameraKeyframe : !selectedObject;
const addKeyframeTitle = addTargetsCamera
? canAddCameraKeyframe
? `把相机「${activeCamera?.name}」当前机位记录为 ${formatTime(playhead)} 处的运镜关键帧(快捷键 K)`
: "锁定物体的相机(targetMode=object)不支持运镜关键帧"
: selectedObject
? `把「${selectedObject.name}」当前位置记录为 ${formatTime(playhead)} 处的关键帧(快捷键 K)`
: "请先选择一个对象或相机";
return (
<section
className={`timeline-panel${draggingTrackKey ? " is-track-dragging" : ""}`}
aria-label="时间轴"
ref={panelRef}
onKeyDown={handlePanelKeyDown}
>
{/* tabIndex 让工具栏区可聚焦:点工具栏空白处后焦点落进面板,
这样「焦点在 timeline-toolbar 时空格切换播放/暂停」才生效(否则焦点在面板外,空格够不到面板级键盘处理) */}
<div className="timeline-toolbar" tabIndex={0}>
<div className="timeline-toolbar-group">
{/* 读数 = 当前帧号 / 当前时间;总时长单独放在右边的时长输入框里 */}
<span
className="timeline-time-readout"
title={`当前第 ${playheadFrame} 帧 / 共 ${totalFrames} 帧(${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>
<div className="timeline-duration-chip" title="时间轴总时长(秒):左右拖动箭头调整,或用上下按钮 ±0.1s">
{/* 拖动把手:按住左右拖动即可减 / 加总时长(也可以聚焦后用左右方向键) */}
<span
className="timeline-duration-drag-handle"
role="slider"
tabIndex={0}
aria-label="拖动调整总时长"
aria-valuemin={MIN_TIMELINE_DURATION}
aria-valuemax={MAX_TIMELINE_DURATION}
aria-valuenow={Number(editableDuration.toFixed(2))}
aria-valuetext={formatTime(editableDuration)}
title="按住左右拖动调整总时长(← / → 每次 0.1s)"
onKeyDown={(event) => {
const delta =
event.key === "ArrowLeft" ? -TIMELINE_DURATION_STEP : event.key === "ArrowRight" ? TIMELINE_DURATION_STEP : 0;
if (delta === 0) return;
event.preventDefault();
nudgeDuration(delta);
}}
{...getDurationDragProps()}
>
<MoveHorizontal aria-hidden="true" size={14} strokeWidth={1.8} />
</span>
<span className="timeline-duration-caption">总时长</span>
<input
className="timeline-duration-input"
aria-label="时间轴总时长"
type="number"
min={MIN_TIMELINE_DURATION}
max={MAX_TIMELINE_DURATION}
step={TIMELINE_DURATION_STEP}
value={editableDuration}
onChange={(event) => setTimelineDuration(Number(event.target.value))}
/>
<span aria-hidden="true">s</span>
{/* 上下微调:以 0.1s 为刻度增减总时长 */}
<span className="timeline-duration-spinner">
<button
className="timeline-duration-spinner-button"
type="button"
aria-label={`总时长增加 ${TIMELINE_DURATION_STEP}s`}
title={`+${TIMELINE_DURATION_STEP}s`}
onClick={() => nudgeDuration(TIMELINE_DURATION_STEP)}
>
<ChevronUp aria-hidden="true" size={12} strokeWidth={2.2} />
</button>
<button
className="timeline-duration-spinner-button"
type="button"
aria-label={`总时长减少 ${TIMELINE_DURATION_STEP}s`}
title={`-${TIMELINE_DURATION_STEP}s`}
onClick={() => nudgeDuration(-TIMELINE_DURATION_STEP)}
>
<ChevronDown aria-hidden="true" size={12} strokeWidth={2.2} />
</button>
</span>
</div>
<div className="timeline-camera-selector" title="当前正在编辑 / 打关键帧的机位,切换后关键帧将归属该机位">
<Camera aria-hidden="true" size={14} strokeWidth={1.8} />
<span className="timeline-camera-selector-caption">机位</span>
<TimelineCameraSelect
cameras={project.cameras}
activeCameraId={activeCamera?.id ?? null}
onSelect={setActiveCamera}
/>
</div>
</div>
<div className="timeline-toolbar-group timeline-toolbar-center">
<div className="timeline-transport" role="group" aria-label="播放控制">
<button
className="timeline-icon-button"
type="button"
aria-label="回到首帧"
title="回到首帧"
onClick={() => seekTo(0)}
>
<SkipBack aria-hidden="true" size={15} strokeWidth={1.8} />
</button>
<button
className="timeline-icon-button timeline-play-button"
type="button"
aria-label={isPlaying ? "暂停" : "播放"}
aria-pressed={isPlaying}
onClick={togglePlaying}
>
{isPlaying ? (
<Pause aria-hidden="true" size={15} strokeWidth={1.8} />
) : (
<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"
aria-label="回到尾帧"
title="回到尾帧"
onClick={() => seekTo(duration)}
>
<SkipForward aria-hidden="true" size={15} strokeWidth={1.8} />
</button>
</div>
<span className="timeline-toolbar-divider" aria-hidden="true" />
<button
className="timeline-action-button"
type="button"
disabled={addKeyframeDisabled}
title={addKeyframeTitle}
onClick={handleAddKeyframe}
>
<DiamondPlus aria-hidden="true" size={14} strokeWidth={1.8} />
在指针处打关键帧
</button>
{selectedKeyframe ? (
<>
<button className="timeline-action-button" type="button" onClick={handleUpdateSelected}>
<RefreshCw aria-hidden="true" size={14} strokeWidth={1.8} />
更新选中关键帧
</button>
<button className="timeline-action-button" type="button" onClick={handleDeleteSelected}>
<Trash2 aria-hidden="true" size={14} strokeWidth={1.8} />
删除选中关键帧
</button>
<label className="timeline-easing-control" title="该关键帧「出去的那一段」使用的缓动曲线">
<span className="timeline-easing-label">缓动</span>
<select
className="timeline-easing-select"
value={selectedKeyframeEasing}
onChange={handleEasingChange}
aria-label="关键帧缓动曲线"
>
{EASING_KINDS.map((kind) => (
<option key={kind} value={kind}>
{EASING_LABELS[kind]}
</option>
))}
</select>
</label>
</>
) : null}
</div>
<div className="timeline-toolbar-group timeline-toolbar-end">
<button
className="timeline-icon-button"
type="button"
aria-haspopup="dialog"
aria-expanded={settingsOpen}
aria-label="时间轴设置"
title="时间轴设置:每秒帧数 / 播放循环"
onClick={openSettings}
>
<Settings2 aria-hidden="true" size={15} strokeWidth={1.8} />
</button>
{hasKeyframes ? (
<button className="timeline-action-button" type="button" onClick={handleClearAll}>
<Eraser aria-hidden="true" size={14} strokeWidth={1.8} />
清空全部
</button>
) : null}
<div className="timeline-export" ref={exportMenuRef}>
<button
className="timeline-export-button"
type="button"
aria-haspopup="menu"
aria-expanded={exportMenuOpen}
disabled={isRecording}
title="导出整段动画为 MP4 视频"
onClick={() => setExportMenuOpen((isOpen) => !isOpen)}
>
<Film aria-hidden="true" size={14} strokeWidth={1.8} />
{isRecording ? "导出中…" : "导出"}
<ChevronDown aria-hidden="true" className="timeline-export-chevron" size={14} strokeWidth={1.8} />
</button>
{exportMenuOpen ? (
<div className="timeline-export-menu" role="menu" aria-label="选择导出视角">
<button
className="timeline-export-option"
type="button"
role="menuitem"
title="按导演视角逐帧快速导出整段动画"
onClick={() => {
setExportMenuOpen(false);
void handleExportVideo("director");
}}
>
<Film aria-hidden="true" size={14} strokeWidth={1.8} />
上帝视角
<span className="timeline-export-option-hint">导演视角整段动画</span>
</button>
<button
className="timeline-export-option"
type="button"
role="menuitem"
title="按当前选中相机的运镜画面逐帧导出"
onClick={() => {
setExportMenuOpen(false);
void handleExportVideo("camera");
}}
>
<Camera aria-hidden="true" size={14} strokeWidth={1.8} />
机位视角
<span className="timeline-export-option-hint">当前相机运镜画面</span>
</button>
</div>
) : null}
</div>
</div>
</div>
<div className="timeline-body">
{hasKeyframes ? null : (
<p className="timeline-empty">还没有关键帧。选中对象或相机,摆好姿态后点「在指针处打关键帧」。</p>
)}
<div className="timeline-grid" style={gridStyle}>
<div className="timeline-grid-head">
<div className="timeline-gutter-cell" aria-hidden="true">
轨道
</div>
<div
className={`timeline-ruler${isScrubbing ? " is-scrubbing" : ""}`}
ref={rulerRef}
role="slider"
tabIndex={0}
aria-label="时间指针"
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={Number(playhead.toFixed(2))}
aria-valuetext={formatTime(playhead)}
onKeyDown={handleRulerKeyDown}
onPointerDown={handleRulerPointerDown}
onPointerMove={handleRulerPointerMove}
onPointerUp={handleRulerPointerEnd}
onPointerCancel={handleRulerPointerEnd}
>
{/* 0.1s 细刻度先画,主刻度的文字与竖线压在上面 */}
{rulerTickNodes}
</div>
</div>
{/* 点轨道空白处(不含片段与关键帧)=取消片段选中 */}
{/* tabIndex 让整块轨道区可聚焦:点轨道空白处后焦点会落进面板,
这样「焦点在轨道上时空格切换播放/暂停」才能生效(否则焦点在面板外,空格够不到面板级的键盘处理) */}
<div className="timeline-grid-body" tabIndex={0} onPointerDown={handleTimelineGridPointerDown}>
{timeline.tracks
.filter((track) => project.objects.find((o) => o.id === track.objectId)?.kind !== "character")
.map((track) => {
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${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">
{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" : ""}${
kf.easing && kf.easing !== "linear" ? " has-easing" : ""
}`}
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" />
</div>
);
})
)}
</div>
</div>
);
})}
{/* 机位切换概览轨:把每个切换切点画成过渡标记(硬切=竖线、平滑=渐变区),点击可编辑类型/时长(逐切点覆盖全局默认) */}
{(() => {
const cuts = getCameraCutSegments(
timeline.cameraTracks,
timeline.cameraTransition,
project.activeCameraId
);
if (cuts.length === 0) return null;
const selectedCut = cuts.find((item) => item.keyframe.id === selectedCutKeyframeId) ?? null;
const selLeft = selectedCut && duration > 0 ? (selectedCut.time / duration) * 100 : 0;
return (
<div className="timeline-track timeline-track-transition">
<div className="timeline-track-label" title="机位切换:每个切换点的过渡类型与时长,点击可编辑">
<Repeat aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">机位切换</span>
<span className="timeline-track-count">{cuts.length}</span>
</div>
<div className="timeline-track-lane timeline-transition-lane">
{cuts.map((cut, index) => {
const leftPercent = duration > 0 ? (cut.time / duration) * 100 : 0;
const nextTime = cuts[index + 1]?.time ?? duration;
const maxDur = Math.max(0, nextTime - cut.time);
const shownDuration =
cut.transition.type === "smooth"
? Math.min(cut.transition.duration, maxDur > 0 ? maxDur : cut.transition.duration)
: 0;
const widthPercent = duration > 0 ? (shownDuration / duration) * 100 : 0;
const isSelected = cut.keyframe.id === selectedCutKeyframeId;
const fromName =
project.cameras.find((c) => c.id === cut.fromCameraId)?.name ?? (cut.fromCameraId ?? "起始");
const toName = project.cameras.find((c) => c.id === cut.toCameraId)?.name ?? cut.toCameraId;
const transitionLabel =
cut.transition.type === "smooth" ? `平滑运镜 ${formatTime(cut.transition.duration)}` : "硬切";
return (
<div
key={cut.keyframe.id}
className={`timeline-transition-marker timeline-transition-${cut.transition.type}${
isSelected ? " is-selected" : ""
}`}
style={
cut.transition.type === "smooth"
? { left: `${leftPercent}%`, width: `${widthPercent}%` }
: { left: `${leftPercent}%` }
}
role="button"
tabIndex={0}
aria-label={`机位切换 ${formatTime(cut.time)} · ${fromName} → ${toName} · ${transitionLabel}`}
title={`${fromName} → ${toName} · ${transitionLabel}(点击编辑)`}
onClick={() => setSelectedCutKeyframeId(isSelected ? null : cut.keyframe.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedCutKeyframeId(isSelected ? null : cut.keyframe.id);
}
}}
>
{cut.transition.type === "smooth" ? (
<span className="timeline-transition-duration">{formatTime(cut.transition.duration)}</span>
) : null}
</div>
);
})}
{selectedCut ? (
<div
className="timeline-transition-editor"
style={{ left: `${selLeft}%` }}
role="dialog"
aria-label="编辑机位切换过渡"
>
<div className="timeline-transition-editor-row">
<span className="timeline-transition-editor-label">类型</span>
<InspectorDropdown
className="timeline-transition-dropdown"
ariaLabel="切换类型"
value={selectedCut.transition.type}
options={[
{ value: "cut", label: "硬切" },
{ value: "smooth", label: "平滑运镜" },
]}
onChange={(value) =>
setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, {
type: value as CameraTransitionType,
duration: selectedCut.transition.duration,
})
}
/>
</div>
<div className="timeline-transition-editor-row">
<span className="timeline-transition-editor-label">时长</span>
<div className="timeline-transition-editor-duration-field">
<input
className="timeline-settings-input timeline-transition-duration-input"
aria-label="过渡时长(秒)"
type="number"
min={MIN_CAMERA_TRANSITION_DURATION}
max={MAX_CAMERA_TRANSITION_DURATION}
step={0.1}
value={selectedCut.transition.duration}
disabled={selectedCut.transition.type !== "smooth"}
onChange={(event) =>
setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, {
type: selectedCut.transition.type,
duration: Math.min(
MAX_CAMERA_TRANSITION_DURATION,
Math.max(MIN_CAMERA_TRANSITION_DURATION, Number(event.target.value))
),
})
}
/>
<span className="timeline-settings-unit">秒</span>
</div>
</div>
<div className="timeline-transition-editor-actions">
<button
type="button"
className="timeline-transition-editor-reset"
disabled={!selectedCut.keyframe.transition}
onClick={() => setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, null)}
>
重置为全局默认
</button>
<button
type="button"
className="timeline-transition-editor-close"
aria-label="关闭编辑器"
onClick={() => setSelectedCutKeyframeId(null)}
>
<X aria-hidden="true" size={12} strokeWidth={2.2} />
</button>
</div>
</div>
) : null}
</div>
</div>
);
})()}
{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);
// 运镜片段:第一个关键帧 → 最后一个关键帧,这段区间就是「这一段运镜」的时长
const cameraTimes = track.keyframes.map((keyframe) => keyframe.time);
const clipSpan = getCameraClipSpan(track.keyframes);
const clipStart = clipSpan?.start ?? 0;
const clipEnd = clipSpan?.end ?? 0;
const clipDuration = clipEnd - clipStart;
const clipLeftPercent = duration > 0 ? (clipStart / duration) * 100 : 0;
const clipWidthPercent = duration > 0 ? (clipDuration / duration) * 100 : 0;
const clipOwner: TimelineTimeDragOwner = { kind: "camera", cameraId: track.cameraId };
// 运镜关键帧连线:在「机位」子轨道上把各关键帧用折线连起来(视图坐标 0~100 对应整条时间轴宽度,
// 50 = 轨道垂直中点),直观呈现运镜过程
const cameraConnectorPoints = cameraTimes
.map((t) => `${(duration > 0 ? (t / duration) * 100 : 0).toFixed(2)},50`)
.join(" ");
return (
<div
className={`timeline-camera-track${collapsed ? " is-collapsed" : ""}${
draggingTrackKey === trackKey ? " is-dragging" : ""
}`}
key={track.cameraId}
data-track-key={trackKey}
style={{ order: getTrackOrder(trackKey) }}
{...dragProps}
>
<div className="timeline-track timeline-track-camera">
<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">
{collapsed ? (
<span className="timeline-track-summary">{track.keyframes.length} 个运镜关键帧</span>
) : cameraTimes.length >= 2 && duration > 0 ? (
<div
className={`timeline-trajectory-bar timeline-clip-bar-camera${
isClipSelected("camera", track.cameraId) ? " is-selected" : ""
}`}
style={{ left: `${clipLeftPercent}%`, width: `${clipWidthPercent}%` }}
title={`运镜片段 ${formatTime(clipStart)} → ${formatTime(
clipEnd
)}(时长 ${formatTime(clipDuration)}):拖动条本体整段平移,拖动左右两端把手分别修剪起点与终点`}
{...getTimeDragProps(clipOwner, "clip", 0, cameraTimes, clipStart)}
>
<span className="timeline-trajectory-bar-label">{formatTime(clipDuration)}</span>
{/* 左端把手 = 修剪起点:末帧不动,首帧推后,各关键帧按比例缩放 */}
<span
className="timeline-trajectory-trim is-pre"
role="slider"
tabIndex={0}
aria-label={`${trackName} 运镜起点`}
aria-valuemin={0}
aria-valuemax={Number(
Math.max(
clipEnd - CAMERA_KEYFRAME_MIN_GAP * Math.max(cameraTimes.length - 1, 0),
0
).toFixed(2)
)}
aria-valuenow={Number(clipStart.toFixed(2))}
aria-valuetext={formatTime(clipStart)}
title={`拖动修剪运镜起点(${formatTime(
clipStart
)} 开始这段运镜,末帧不动、其余关键帧按比例缩放),或用左右方向键微调`}
onKeyDown={(event) => handleTrimStartKeyDown(event, clipOwner, cameraTimes, clipStart)}
{...getTimeDragProps(clipOwner, "trimStart", 0, cameraTimes, clipStart)}
/>
{/* 右端把手 = 修剪终点:首帧不动,末帧伸缩 */}
<span
className="timeline-trajectory-trim is-post"
role="slider"
tabIndex={0}
aria-label={`${trackName} 运镜时长`}
aria-valuemin={Math.min(clipStart + CAMERA_KEYFRAME_MIN_GAP, clipEnd)}
aria-valuemax={Math.max(duration, clipEnd)}
aria-valuenow={Number(clipEnd.toFixed(2))}
aria-valuetext={`${formatTime(clipDuration)},结束于 ${formatTime(clipEnd)}`}
title="拖动整体缩放这段运镜的时长(第一个运镜关键帧固定不动),或用左右方向键微调"
onKeyDown={(event) =>
handleTimeHandleKeyDown(event, clipOwner, cameraTimes, clipStart)
}
{...getTimeDragProps(
clipOwner,
"duration",
cameraTimes.length - 1,
cameraTimes,
clipStart
)}
/>
</div>
) : null}
</div>
</div>
{collapsed ? null : (
<>
{/* 机位子轨道:运镜关键帧单独一条轨道,并用折线把相邻关键帧连起来呈现运镜过程 */}
<div
className="timeline-subtrack"
title="机位关键帧:每个运镜关键帧,可单独左右拖动改时间,选中后点 × 或按 Delete 删除。折线连接相邻关键帧呈现运镜过程"
>
<div className="timeline-subtrack-label">关键帧</div>
<div className="timeline-track-lane timeline-camera-keyframe-lane">
{cameraTimes.length > 0 && duration > 0 ? (
<>
{/* 折线连接各运镜关键帧,呈现运镜过程 */}
<svg
className="timeline-trajectory-connector"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline points={cameraConnectorPoints} />
</svg>
{track.keyframes.map((kf, index) => {
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" : ""
}${kf.easing && kf.easing !== "linear" ? " has-easing" : ""}`}
style={{ left: `${leftPercent}%` }}
role="button"
tabIndex={0}
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
title={`运镜关键帧 · ${formatTime(kf.time)}(左右拖动调整时间)`}
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
{...getTimeDragProps(clipOwner, "point", index, cameraTimes)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
</div>
);
})}
</>
) : null}
</div>
</div>
</>
)}
</div>
);
})}
{characterObjects.map((character) => {
const trajectoryPoints = character.trajectoryPoints ?? [];
// 动作片段序列:同一角色可叠加多段(如先走再跑)。片段之间允许重叠,后者覆盖前者。
const clips = character.characterRig?.motionClips ?? [];
const motionTitle = `动作轨道:共 ${clips.length} 段动作片段(点击片段选中后在右侧编辑;拖拽条体或两端把手调整时间)`;
const trackKey = getTimelineTrackKey("character", character.id);
const collapsed = timelineCollapsedTracks.includes(trackKey);
const dragProps = getTrackDragProps(trackKey);
// 轨迹片段的起点(拖条本体可整段平移,0 表示从 0s 就走)、每个轨迹点的关键帧时间,
// 以及片段结束时间(角色走完最后一点后原地停留到这一刻,即「停留时长」)。
// 默认结束时间 = 时间轴末尾,所以最后一个点落在时间结束处;一旦设置 trajectoryEndTime,
// 最后一个点只是路径中途的途经点,条右边缘到结束时间之间都是停留段。
const trajectoryStart = getTrajectoryStartTime(character, duration);
const trajectoryTimes = getTrajectoryPointTimes(trajectoryPoints, duration, trajectoryStart);
const trajectoryEndTime = getTrajectoryClipEndTime(character, duration, trajectoryStart);
// 轨迹点连线:在「轨迹」轨道上把各轨迹点用折线连起来(视图坐标 0~100 对应整条时间轴宽度,
// 50 = 轨道垂直中点),直观呈现走位路径(最后一个点只是途经点,后面是停留段)
const trajectoryConnectorPoints = [trajectoryStart, ...trajectoryTimes]
.map((t) => `${(duration > 0 ? (t / duration) * 100 : 0).toFixed(2)},50`)
.join(" ");
const trajectoryLeftPercent = duration > 0 ? (trajectoryStart / duration) * 100 : 0;
const trajectoryWidthPercent =
duration > 0 ? Math.min(100, ((trajectoryEndTime - trajectoryStart) / duration) * 100) : 0;
// 条右端把手 / 键盘微调改的是「整段轨迹时长」,不针对某个点,pointId 留空
const trajectoryOwner: TimelineTimeDragOwner = {
kind: "trajectory",
objectId: character.id,
pointId: "",
};
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">
{clips.length > 0 ? `${clips.length} 段动作 · ` : ""}
{trajectoryPoints.length} 个轨迹点
</span>
) : trajectoryTimes.length > 0 && duration > 0 ? (
<div
className={`timeline-trajectory-bar${
isClipSelected("trajectory", character.id) ? " is-selected" : ""
}`}
style={{ left: `${trajectoryLeftPercent}%`, width: `${trajectoryWidthPercent}%` }}
title={`轨迹片段 ${formatTime(trajectoryStart)} → ${formatTime(
trajectoryEndTime
)}(时长 ${formatTime(
trajectoryEndTime - trajectoryStart
)},结尾停留到 ${formatTime(
trajectoryEndTime
)}):拖动条本体整段平移,拖动左右两端把手分别修剪起点与停留终点`}
{...getTimeDragProps(trajectoryOwner, "clip", 0, trajectoryTimes, trajectoryStart)}
>
<span className="timeline-trajectory-bar-label">{formatTime(trajectoryEndTime)}</span>
{/* 左端把手 = 修剪起点:只挪片段起点(角色在此之前原地等待),轨迹点留在原地 */}
<span
className="timeline-trajectory-trim is-pre"
role="slider"
tabIndex={0}
aria-label={`${character.name} 轨迹起点`}
aria-valuemin={0}
aria-valuemax={Number(
Math.max((trajectoryTimes[0] ?? 0) - TRAJECTORY_POINT_MIN_GAP, 0).toFixed(2)
)}
aria-valuenow={Number(trajectoryStart.toFixed(2))}
aria-valuetext={formatTime(trajectoryStart)}
title={`拖动修剪轨迹起点(${formatTime(
trajectoryStart
)} 之前角色原地等待),或用左右方向键微调`}
onKeyDown={(event) =>
handleTrimStartKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryStart)
}
{...getTimeDragProps(trajectoryOwner, "trimStart", 0, trajectoryTimes, trajectoryStart)}
/>
{/* 右端把手 = 修剪停留终点:角色走完最后一点后停留到这一刻 */}
<span
className="timeline-trajectory-trim is-post"
role="slider"
tabIndex={0}
aria-label={`${character.name} 轨迹停留时长`}
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={Number(trajectoryEndTime.toFixed(2))}
aria-valuetext={formatTime(trajectoryEndTime)}
title="拖动调整轨迹停留时长(角色走完最后一点后原地停留到这一刻),或用左右方向键微调"
onKeyDown={(event) =>
handleTimeHandleKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryEndTime)
}
{...getTimeDragProps(
trajectoryOwner,
"duration",
trajectoryTimes.length - 1,
trajectoryTimes,
trajectoryEndTime
)}
/>
</div>
) : null}
</div>
</div>
{collapsed ? null : (
<>
{/* 动作轨道:角色的基础动作序列,可添加多段(如先走再跑),每段独立可拖拽 / 删除 */}
<div className="timeline-subtrack" title={motionTitle}>
<div className="timeline-subtrack-label timeline-subtrack-label-action">
<span>动作</span>
<button
type="button"
className="timeline-add-clip-button"
aria-label={`${character.name} 添加动作片段`}
title="添加动作片段:在当前播放头位置新增一段动作(如先走再跑),形成动作序列"
onClick={() => {
selectObject(character.id);
addMotionClip(character.id, { start: playhead });
}}
>
<Plus aria-hidden="true" size={12} strokeWidth={2.4} />
</button>
</div>
<div className="timeline-track-lane timeline-motion-lane">
{clips.length === 0 ? (
<span className="timeline-motion-chip">暂无动作 · 点「+」添加</span>
) : (
clips.map((clip) => {
const clipStart = clip.start;
const clipEnd = Number.isFinite(clip.end) ? clip.end : duration;
const clipLeftPercent = duration > 0 ? (clipStart / duration) * 100 : 0;
const clipWidthPercent =
duration > 0 ? Math.min(100, ((clipEnd - clipStart) / duration) * 100) : 0;
const clipLabel = MOTION_LABELS[clip.kind];
const clipTimes = [clipStart, clipEnd];
const clipOwner: TimelineTimeDragOwner = {
kind: "motionClip",
objectId: character.id,
clipId: clip.id,
};
const isSelected =
selectedMotionClipId === clip.id || isClipSelected("motionClip", clip.id);
return (
<div
key={clip.id}
className={`timeline-trajectory-bar timeline-clip-bar-motion${
isSelected ? " is-selected" : ""
}${clip.enabled === false ? " is-disabled" : ""}`}
style={{ left: `${clipLeftPercent}%`, width: `${clipWidthPercent}%` }}
title={`动作片段 ${formatTime(clipStart)} → ${formatTime(clipEnd)}(时长 ${formatTime(
clipEnd - clipStart
)}):${clipLabel} · 拖动条本体整段平移,拖动左右两端把手分别修剪起点与停止时间`}
onClick={() => {
selectObject(character.id);
selectMotionClip(clip.id);
}}
{...getTimeDragProps(clipOwner, "clip", 0, clipTimes, clipStart, clipEnd)}
>
<span className="timeline-trajectory-bar-label">{clipLabel}</span>
<button
type="button"
className="timeline-clip-delete"
aria-label={`${character.name} 删除动作片段`}
title="删除该动作片段"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
removeMotionClip(character.id, clip.id);
}}
>
<X aria-hidden="true" size={10} strokeWidth={2.6} />
</button>
{/* 左端把手 = 修剪动作起点:之前角色保持静止,到这一刻才开始动 */}
<span
className="timeline-trajectory-trim is-pre"
role="slider"
tabIndex={0}
aria-label={`${character.name} 动作起点`}
aria-valuemin={0}
aria-valuemax={Math.max(clipEnd - 0.1, 0)}
aria-valuenow={Number(clipStart.toFixed(2))}
aria-valuetext={formatTime(clipStart)}
title={`拖动修剪动作起点(${formatTime(clipStart)} 之前角色保持静止),或用左右方向键微调`}
onKeyDown={(event) =>
handleTrimStartKeyDown(event, clipOwner, clipTimes, clipStart)
}
{...getTimeDragProps(clipOwner, "trimStart", 0, clipTimes, clipStart, clipEnd)}
/>
{/* 右端把手 = 停止时间:动作播放到这一刻停止,角色回到静止 */}
<span
className="timeline-trajectory-trim is-post"
role="slider"
tabIndex={0}
aria-label={`${character.name} 动作停止时间`}
aria-valuemin={Number(clipStart.toFixed(2))}
aria-valuemax={duration}
aria-valuenow={Number(clipEnd.toFixed(2))}
aria-valuetext={formatTime(clipEnd)}
title="拖动调整动作停止时间(动作播放到这一刻停止),或用左右方向键微调"
onKeyDown={(event) =>
handleTimeHandleKeyDown(event, clipOwner, clipTimes, clipEnd)
}
{...getTimeDragProps(
clipOwner,
"duration",
1,
clipTimes,
clipStart,
clipEnd
)}
/>
</div>
);
})
)}
</div>
</div>
{/* 人物轨迹轨道:一条「轨迹」子轨道,显示各轨迹点关键帧(小菱形),
并用折线把点连起来呈现走位路径。点可单独左右拖动改时间,选中后按 Delete 删除 */}
<div
className="timeline-subtrack"
title="人物轨迹:每个轨迹点一条关键帧,可单独左右拖动改时间,选中后按 Delete 删除"
>
<div className="timeline-subtrack-label">轨迹</div>
<div className="timeline-track-lane timeline-trajectory-lane">
{trajectoryTimes.length > 0 && duration > 0 ? (
<>
{/* 折线连接各轨迹点,呈现走位路径(最后一个点只是途经点) */}
<svg
className="timeline-trajectory-connector"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline points={trajectoryConnectorPoints} />
</svg>
{trajectoryPoints.map((point, index) => {
const pointTime = trajectoryTimes[index] ?? 0;
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
)}(左右拖动调整时间,选中后按 Delete 删除)`}
onClick={() => handleSelectTrajectoryKeyframe(character.id, point.id, pointTime)}
{...getTimeDragProps(
{ kind: "trajectory", objectId: character.id, pointId: point.id },
"point",
index,
trajectoryTimes,
trajectoryStart,
trajectoryEndTime
)}
>
<span className="timeline-keyframe-dot" aria-hidden="true" />
</div>
);
})}
</>
) : null}
</div>
</div>
</>
)}
</div>
);
})}
{/* 拖拽落点线:绝对定位在轨道区上,靠 transform 移动(详见 showDropIndicator) */}
<div className="timeline-drop-indicator" ref={dropIndicatorRef} aria-hidden="true" />
</div>
<div className="timeline-playhead-layer" aria-hidden="true">
<div className="timeline-playhead" style={{ left: `${playheadPercent}%` }}>
<span className="timeline-playhead-knob" />
</div>
{/* 吸附参考线:拖动片段 / 关键帧时,边缘吸到其它片段边缘或播放头处显示 */}
<div className="timeline-snap-indicator" ref={snapIndicatorRef} />
</div>
</div>
</div>
{/* 时间轴设置:帧率 / 循环先改草稿,点「完成」才写回工程并生效。
用 portal 挂到 body:时间轴面板带 backdrop-filter,会成为 fixed 元素的包含块,
直接挂在面板里会被缩在面板内并被裁掉。 */}
{settingsOpen && typeof document !== "undefined"
? createPortal(
<div
className="timeline-settings-overlay"
data-testid="timeline-settings-overlay"
>
<div
className="timeline-settings-dialog"
role="dialog"
aria-modal="true"
aria-label="时间轴设置"
onClick={(event) => event.stopPropagation()}
>
<div className="timeline-settings-head">
<h3 className="timeline-settings-title">时间轴设置</h3>
{/* 右上角关闭按钮:等同取消,草稿值不写回工程 */}
<button
className="timeline-settings-close"
type="button"
aria-label="关闭设置"
title="关闭(不保存设置)"
onClick={() => setSettingsOpen(false)}
>
<X aria-hidden="true" size={14} strokeWidth={2} />
</button>
</div>
<div className="timeline-settings-field">
<span className="timeline-settings-field-label">每秒帧数</span>
<InspectorDropdown
ariaLabel="每秒帧数"
value={String(draftFps)}
options={fpsOptions.map((value) => ({ value: String(value), label: `${value} fps` }))}
onChange={(selected) => setDraftFps(Number(selected))}
/>
</div>
<label className="timeline-settings-toggle">
<input
type="checkbox"
aria-label="播放循环"
checked={draftLoop}
onChange={(event) => setDraftLoop(event.target.checked)}
/>
<span>播放循环(播到末尾自动回到起点接着播)</span>
</label>
<div className="timeline-settings-divider" />
<h4 className="timeline-settings-subtitle">机位切换过渡</h4>
<div className="timeline-settings-field">
<span className="timeline-settings-field-label">切换方式</span>
<InspectorDropdown
ariaLabel="机位切换过渡方式"
value={draftTransitionType}
options={[
{ value: "cut", label: "硬切" },
{ value: "smooth", label: "平滑运镜" },
]}
onChange={(selected) => setDraftTransitionType(selected as CameraTransitionType)}
/>
</div>
<label className="timeline-settings-field">
<span className="timeline-settings-field-label">过渡时长</span>
<span className="timeline-settings-field-control">
<input
className="timeline-settings-input"
aria-label="过渡时长"
type="number"
min={MIN_CAMERA_TRANSITION_DURATION}
max={MAX_CAMERA_TRANSITION_DURATION}
step={0.1}
value={draftTransitionDuration}
onChange={(event) => setDraftTransitionDuration(Number(event.target.value))}
/>
<span className="timeline-settings-unit">秒</span>
</span>
</label>
<div className="timeline-settings-divider" />
<h4 className="timeline-settings-subtitle">视频导出</h4>
<div className="timeline-settings-field">
<span className="timeline-settings-field-label">分辨率(短边)</span>
<InspectorDropdown
ariaLabel="导出分辨率"
value={String(draftResolution)}
options={EXPORT_RESOLUTION_OPTIONS.map((opt) => ({ value: String(opt.value), label: opt.label }))}
onChange={(selected) => setDraftResolution(Number(selected))}
/>
</div>
<div className="timeline-settings-field">
<span className="timeline-settings-field-label">画质 / 码率</span>
<InspectorDropdown
ariaLabel="导出画质"
value={draftQuality}
options={EXPORT_QUALITY_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label }))}
onChange={(selected) => setDraftQuality(selected as ExportQuality)}
/>
</div>
{draftQuality === "custom" ? (
<label className="timeline-settings-field">
<span className="timeline-settings-field-label">自定义码率</span>
<span className="timeline-settings-field-control">
<input
className="timeline-settings-input"
aria-label="自定义码率(Mbps)"
type="number"
min={0.1}
step={0.5}
value={Math.round((draftCustomBitrate / 1_000_000) * 10) / 10}
onChange={(event) =>
setDraftCustomBitrate(
Math.max(100_000, Math.round(Number(event.target.value) * 1_000_000))
)
}
/>
<span className="timeline-settings-unit">Mbps</span>
</span>
</label>
) : null}
<div className="timeline-settings-field">
<span className="timeline-settings-field-label">编码档位</span>
<InspectorDropdown
ariaLabel="导出编码档位"
value={draftCodec}
options={EXPORT_CODEC_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label }))}
onChange={(selected) => setDraftCodec(selected as ExportCodecPreference)}
/>
</div>
<p className="timeline-settings-hint">
帧率决定时间读数与导出视频的帧数;分辨率 / 画质 / 编码在此设置,点「完成」后才保存并应用。
</p>
<div className="timeline-settings-actions">
<button className="timeline-settings-confirm" type="button" onClick={confirmSettings}>
完成
</button>
</div>
</div>
</div>,
document.body
)
: null}
</section>
);
}
/**
* 时间轴工具栏的「当前机位」选择器:自定义下拉(非原生 <select>),
* 这样深色主题下选项文字也能正常显示,并跟随主题配色。
* 展开后列出全部机位,当前机位高亮并打勾;点击外部 / Esc 关闭。
*/
function TimelineCameraSelect({
cameras,
activeCameraId,
onSelect,
}: {
cameras: DirectorCameraShot[];
activeCameraId: string | null;
onSelect: (id: string) => void;
}) {
return (
<InspectorDropdown
ariaLabel="当前机位"
className="timeline-camera-dropdown"
value={activeCameraId ?? ""}
placeholder="未选择机位"
options={cameras.map((camera) => ({ value: camera.id, label: camera.name }))}
onChange={onSelect}
/>
);
}