2685 lines
124 KiB
TypeScript
2685 lines
124 KiB
TypeScript
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}
|
||
/>
|
||
);
|
||
}
|