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 = { character: PersonStanding, camera: Video, prop: Box, }; /** 人物轨道「动作」子轨道里展示的基础动作名 */ const MOTION_LABELS: Record = { 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(); 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 ( ); } 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(null); /** 选中的片段(轨迹条 / 运镜条):点条本体或条上的关键帧都会选中它 */ const [selectedClip, setSelectedClip] = useState(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("smooth"); const [draftTransitionDuration, setDraftTransitionDuration] = useState(DEFAULT_CAMERA_TRANSITION_DURATION); // 导出设置草稿:分辨率 / 画质 / 自定义码率 / 编码档位,点「完成」才写回工程 const [draftResolution, setDraftResolution] = useState(720); const [draftQuality, setDraftQuality] = useState("medium"); const [draftCustomBitrate, setDraftCustomBitrate] = useState(8_000_000); const [draftCodec, setDraftCodec] = useState("auto"); /** 拖动总时长:按下时记一次起始时长与指针位置,移动中只做线性换算 */ const durationDragRef = useRef<{ pointerId: number; startClientX: number; startDuration: number } | null>(null); // 轨道拖拽:生命周期(谁在拖 → 高亮 / 毛玻璃降级)走 React state,一次拖拽只重渲染两次; // 高频的落点线则完全不经过 React(ref + transform),详见 getTrackDragProps。 const [draggingTrackKey, setDraggingTrackKey] = useState(null); /** 当前在「机位切换」概览轨中选中的过渡(按切点关键帧 id 定位),用于弹出编辑器 */ const [selectedCutKeyframeId, setSelectedCutKeyframeId] = useState(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(null); /** 吸附参考线:拖动片段 / 关键帧时,边缘吸到其它片段边缘或播放头处显示一条高亮竖线 */ const snapIndicatorRef = useRef(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()); const trackLayoutOriginRef = useRef(0); const panelRef = useRef(null); const exportMenuRef = useRef(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) { 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) { 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) { 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) => { 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(null); const rulerRectRef = useRef(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) => ( ))} {rulerTicks.map((tick) => ( {formatRulerTick(tick.value)} ))} ), [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 ( toggleTimelineTrackCollapsed(trackKey)} /> ); } /** 拖拽开始时量一次所有轨道的几何(此后拖拽期间不再读布局) */ function measureTrackRows() { const panel = panelRef.current; const body = panel?.querySelector(".timeline-grid-body"); trackLayoutOriginRef.current = body ? body.getBoundingClientRect().top : 0; const rects = new Map(); panel?.querySelectorAll("[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, 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) { // 点在交互元素(片段条 / 关键帧 / 拖拽把手 / 删除按钮 / 添加按钮 / 折叠开关)上不算空白处, // 那些手势自己会处理选中,这里不能清掉它们。 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, 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) { 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) { 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) => beginTimeDrag(event, owner, kind, index, times, startEdge, endEdge), onPointerMove: handleTimeDragMove, onPointerUp: handleTimeDragEnd, onPointerCancel: handleTimeDragEnd, }; } type TimeDragGesture = NonNullable; /** * 收集拖拽时可吸附的目标时刻:同轨道(同角色 / 同相机)其它片段的边缘 + 播放头。 * 过滤掉被拖元素自身当前的边缘 / 关键帧时刻,避免「吸回原处」导致无法小幅移动。 */ 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, 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, 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) { 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) { 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) => 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) { // 每次开始拖动重新量一次刻度尺(窗口可能刚被缩放) 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) { if (!isScrubbing) return; const time = getTimeFromPointer(event.clientX); if (time !== null) setPlayhead(time); } function handleRulerPointerEnd(event: ReactPointerEvent) { if (!isScrubbing) return; setIsScrubbing(false); setScrubbing(false); rulerRectRef.current = null; try { event.currentTarget.releasePointerCapture(event.pointerId); } catch { // 同上,忽略不支持的环境 } } function handleRulerKeyDown(event: ReactKeyboardEvent) { const step = duration <= 10 ? 0.1 : 0.5; const seekActions: Record 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) { 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 (
{/* tabIndex 让工具栏区可聚焦:点工具栏空白处后焦点落进面板, 这样「焦点在 timeline-toolbar 时空格切换播放/暂停」才生效(否则焦点在面板外,空格够不到面板级键盘处理) */}
{/* 读数 = 当前帧号 / 当前时间;总时长单独放在右边的时长输入框里 */} F{playheadFrame} {formatReadoutTime(playhead)}
{/* 拖动把手:按住左右拖动即可减 / 加总时长(也可以聚焦后用左右方向键) */} { const delta = event.key === "ArrowLeft" ? -TIMELINE_DURATION_STEP : event.key === "ArrowRight" ? TIMELINE_DURATION_STEP : 0; if (delta === 0) return; event.preventDefault(); nudgeDuration(delta); }} {...getDurationDragProps()} > 总时长 setTimelineDuration(Number(event.target.value))} /> {/* 上下微调:以 0.1s 为刻度增减总时长 */}
{hasKeyframes ? ( ) : null}
{exportMenuOpen ? (
) : null}
{hasKeyframes ? null : (

还没有关键帧。选中对象或相机,摆好姿态后点「在指针处打关键帧」。

)}
{/* 0.1s 细刻度先画,主刻度的文字与竖线压在上面 */} {rulerTickNodes}
{/* 点轨道空白处(不含片段与关键帧)=取消片段选中 */} {/* tabIndex 让整块轨道区可聚焦:点轨道空白处后焦点会落进面板, 这样「焦点在轨道上时空格切换播放/暂停」才能生效(否则焦点在面板外,空格够不到面板级的键盘处理) */}
{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 (
{renderCollapseToggle(trackKey, trackName, "物体轨道", collapsed)}
{collapsed ? ( {track.keyframes.length} 个关键帧 ) : ( 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 (
handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)} >
); }) )}
); })} {/* 机位切换概览轨:把每个切换切点画成过渡标记(硬切=竖线、平滑=渐变区),点击可编辑类型/时长(逐切点覆盖全局默认) */} {(() => { 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 (
{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 (
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" ? ( {formatTime(cut.transition.duration)} ) : null}
); })} {selectedCut ? (
类型 setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, { type: value as CameraTransitionType, duration: selectedCut.transition.duration, }) } />
时长
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)) ), }) } /> 秒
) : null}
); })()} {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 (
{renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
{/* 运镜轨道头部车道:展开时放「运镜片段条」,折叠时放一行摘要(与角色轨道头部一致) */}
{collapsed ? ( {track.keyframes.length} 个运镜关键帧 ) : cameraTimes.length >= 2 && duration > 0 ? (
{formatTime(clipDuration)} {/* 左端把手 = 修剪起点:末帧不动,首帧推后,各关键帧按比例缩放 */} handleTrimStartKeyDown(event, clipOwner, cameraTimes, clipStart)} {...getTimeDragProps(clipOwner, "trimStart", 0, cameraTimes, clipStart)} /> {/* 右端把手 = 修剪终点:首帧不动,末帧伸缩 */} handleTimeHandleKeyDown(event, clipOwner, cameraTimes, clipStart) } {...getTimeDragProps( clipOwner, "duration", cameraTimes.length - 1, cameraTimes, clipStart )} />
) : null}
{collapsed ? null : ( <> {/* 机位子轨道:运镜关键帧单独一条轨道,并用折线把相邻关键帧连起来呈现运镜过程 */}
关键帧
{cameraTimes.length > 0 && duration > 0 ? ( <> {/* 折线连接各运镜关键帧,呈现运镜过程 */} {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 (
handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)} {...getTimeDragProps(clipOwner, "point", index, cameraTimes)} >
); })} ) : null}
)}
); })} {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 (
{/* 分组抬头:折叠开关 + 角色名 + 轨迹点数(整组可拖动排序) */}
{renderCollapseToggle(trackKey, character.name, "角色轨道", collapsed)}
{/* 分组抬头行:展开时显示轨迹片段条,折叠时显示一行摘要 */}
{collapsed ? ( {clips.length > 0 ? `${clips.length} 段动作 · ` : ""} {trajectoryPoints.length} 个轨迹点 ) : trajectoryTimes.length > 0 && duration > 0 ? (
{formatTime(trajectoryEndTime)} {/* 左端把手 = 修剪起点:只挪片段起点(角色在此之前原地等待),轨迹点留在原地 */} handleTrimStartKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryStart) } {...getTimeDragProps(trajectoryOwner, "trimStart", 0, trajectoryTimes, trajectoryStart)} /> {/* 右端把手 = 修剪停留终点:角色走完最后一点后停留到这一刻 */} handleTimeHandleKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryEndTime) } {...getTimeDragProps( trajectoryOwner, "duration", trajectoryTimes.length - 1, trajectoryTimes, trajectoryEndTime )} />
) : null}
{collapsed ? null : ( <> {/* 动作轨道:角色的基础动作序列,可添加多段(如先走再跑),每段独立可拖拽 / 删除 */}
动作
{clips.length === 0 ? ( 暂无动作 · 点「+」添加 ) : ( 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 (
{ selectObject(character.id); selectMotionClip(clip.id); }} {...getTimeDragProps(clipOwner, "clip", 0, clipTimes, clipStart, clipEnd)} > {clipLabel} {/* 左端把手 = 修剪动作起点:之前角色保持静止,到这一刻才开始动 */} handleTrimStartKeyDown(event, clipOwner, clipTimes, clipStart) } {...getTimeDragProps(clipOwner, "trimStart", 0, clipTimes, clipStart, clipEnd)} /> {/* 右端把手 = 停止时间:动作播放到这一刻停止,角色回到静止 */} handleTimeHandleKeyDown(event, clipOwner, clipTimes, clipEnd) } {...getTimeDragProps( clipOwner, "duration", 1, clipTimes, clipStart, clipEnd )} />
); }) )}
{/* 人物轨迹轨道:一条「轨迹」子轨道,显示各轨迹点关键帧(小菱形), 并用折线把点连起来呈现走位路径。点可单独左右拖动改时间,选中后按 Delete 删除 */}
轨迹
{trajectoryTimes.length > 0 && duration > 0 ? ( <> {/* 折线连接各轨迹点,呈现走位路径(最后一个点只是途经点) */} {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 (
handleSelectTrajectoryKeyframe(character.id, point.id, pointTime)} {...getTimeDragProps( { kind: "trajectory", objectId: character.id, pointId: point.id }, "point", index, trajectoryTimes, trajectoryStart, trajectoryEndTime )} >
); })} ) : null}
)}
); })} {/* 拖拽落点线:绝对定位在轨道区上,靠 transform 移动(详见 showDropIndicator) */}
{/* 时间轴设置:帧率 / 循环先改草稿,点「完成」才写回工程并生效。 用 portal 挂到 body:时间轴面板带 backdrop-filter,会成为 fixed 元素的包含块, 直接挂在面板里会被缩在面板内并被裁掉。 */} {settingsOpen && typeof document !== "undefined" ? createPortal(
event.stopPropagation()} >

时间轴设置

{/* 右上角关闭按钮:等同取消,草稿值不写回工程 */}
每秒帧数 ({ value: String(value), label: `${value} fps` }))} onChange={(selected) => setDraftFps(Number(selected))} />

机位切换过渡

切换方式 setDraftTransitionType(selected as CameraTransitionType)} />

视频导出

分辨率(短边) ({ value: String(opt.value), label: opt.label }))} onChange={(selected) => setDraftResolution(Number(selected))} />
画质 / 码率 ({ value: opt.value, label: opt.label }))} onChange={(selected) => setDraftQuality(selected as ExportQuality)} />
{draftQuality === "custom" ? ( ) : null}
编码档位 ({ value: opt.value, label: opt.label }))} onChange={(selected) => setDraftCodec(selected as ExportCodecPreference)} />

帧率决定时间读数与导出视频的帧数;分辨率 / 画质 / 编码在此设置,点「完成」后才保存并应用。

, document.body ) : null}
); } /** * 时间轴工具栏的「当前机位」选择器:自定义下拉(非原生