feat(时间轴): 支持多机位自动切换与关键帧

- 新增 getActiveCameraIdAtTime,按时间轴关键帧自动选择机位
- 导出与播放时预览按切点自动切机位,保持一致
- 时间轴工具栏新增当前机位选择器
- 无选中物体时给当前机位打关键帧,支持多机位独立打点
This commit is contained in:
2026-09-19 02:43:47 +08:00
parent 2015d8d3c3
commit 482d649f84
8 changed files with 464 additions and 54 deletions

View File

@@ -8,7 +8,7 @@ import { hideViewportCaptureHelpers, withViewportCaptureHelpersHidden } from "..
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms";
import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, type CameraViewSnapshot } from "../schema/cameraGeometry";
import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio";
import { normalizeTimelineFps } from "../schema/timeline";
import { getActiveCameraIdAtTime, normalizeTimelineFps } from "../schema/timeline";
export type VideoExportView = "director" | "camera";
@@ -182,18 +182,9 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
// 只等一个宏任务不够(截图路径同样要等一帧)。导出循环里也每帧取最新引用兜底。
await waitNextFrame();
// 切完视角再取当前机位:setViewMode("camera") 会在 activeCameraId 为空时补上第一个机位,
// 所以不能用上面的 directorStore 快照(那时可能还是 null)。
const exportState = useDirectorStore.getState();
const activeCameraShot =
view === "camera"
? exportState.project.cameras.find((item) => item.id === exportState.project.activeCameraId) ?? null
: null;
// 机位线框 / 三轴 gizmo 都在场景组里,导出相机也要落在世界坐标(场景变换之内),
// 否则场景一缩放 / 旋转,导出画面里机位所在的世界位置和编辑器里看到的对不上。
const activeCameraView = activeCameraShot
? getWorldCameraViewSnapshot(getCameraViewSnapshotFromShot(activeCameraShot), exportState.project.scene)
: null;
// 切完视角无需固定单一机位:机位视角下每帧按关键帧时间智能切机位
// (getActiveCameraIdAtTime 取「≤ 当前时刻最近关键帧」所属机位),导出画面随切点自动切换。
// 因此下面循环里逐帧计算「该帧应显示的机位」,而不在循环外写死。
const frameDurationUs = Math.round(1_000_000 / fps);
try {
@@ -218,11 +209,21 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
usePlaybackStore.getState().editingObjectId,
{ objects: directorStore.project.objects, duration }
);
// 机位视角下逐帧决定「该帧显示哪个机位」:取所有相机轨道里「≤ 当前时刻最近关键帧」
// 时间最大者所属机位;没有任何机位在 t 之前打过关键帧时退回当前激活机位。
const displayCameraId =
view === "camera"
? getActiveCameraIdAtTime(
directorStore.project.timeline.cameraTracks,
directorStore.project.activeCameraId,
time
)
: null;
// 有运镜轨道时两边共用同一份插值(预览 / 导出一致);返回 false 表示没覆盖相机
const appliedCameraTrack = applyActiveCameraViewAtTime({
camera: frameCamera,
cameraTracks: directorStore.project.timeline.cameraTracks,
activeCameraId: exportState.project.activeCameraId,
activeCameraId: displayCameraId,
playhead: time,
viewMode: view,
editingObjectId: usePlaybackStore.getState().editingObjectId,
@@ -230,8 +231,18 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
// 没覆盖 = 这个机位没有运镜轨道。此时必须把机位自己的视角写进导出相机,
// 否则画面取决于「此刻默认相机是谁」,换相机稍有差池就渲成导演视角(更高、更远)。
if (!appliedCameraTrack && activeCameraView) {
applyCameraSnapshotToFrameCamera(frameCamera, activeCameraView);
if (!appliedCameraTrack && displayCameraId) {
const shot =
directorStore.project.cameras.find((item) => item.id === displayCameraId) ?? null;
if (shot) {
// 机位线框 / 三轴 gizmo 都在场景组里,导出相机也要落在世界坐标(场景变换之内),
// 否则场景一缩放 / 旋转,导出画面里机位所在的世界位置和编辑器里看到的对不上。
const worldView = getWorldCameraViewSnapshot(
getCameraViewSnapshotFromShot(shot),
directorStore.project.scene
);
applyCameraSnapshotToFrameCamera(frameCamera, worldView);
}
}
// 和截图同一套:渲染这一帧前先把编辑器辅助元素(gizmo 红绿蓝轴 / 网格 / 坐标轴 / 轨迹)藏起来,
@@ -375,8 +386,11 @@ async function exportVideoWithMediaRecorder(view: VideoExportView): Promise<void
* 只把指针放回导出前的位置,循环设置原样还回去。
*/
export async function exportVideo(view: VideoExportView): Promise<void> {
const previousLoop = usePlaybackStore.getState().loop;
usePlaybackStore.getState().setLoop(false);
const playback = usePlaybackStore.getState();
const previousLoop = playback.loop;
playback.setLoop(false);
// 标记正在导出:机位视角下让屏幕预览按时间轴自动切机位,和成片一致(导出时并未播放)
playback.setExporting(true);
try {
if (typeof VideoEncoder !== "undefined") {
@@ -385,8 +399,9 @@ export async function exportVideo(view: VideoExportView): Promise<void> {
}
await exportVideoWithMediaRecorder(view);
} finally {
const playback = usePlaybackStore.getState();
playback.setLoop(previousLoop);
playback.setPlaying(false);
const restored = usePlaybackStore.getState();
restored.setExporting(false);
restored.setLoop(previousLoop);
restored.setPlaying(false);
}
}

View File

@@ -209,9 +209,43 @@ it("toggles to pause when play button clicked", () => {
expect(screen.getByRole("button", { name: "暂停" })).toBeInTheDocument();
});
it("disables add-keyframe button when no object is selected", () => {
it("adds a camera keyframe for the active camera when no object is selected", () => {
useDirectorStore.getState().selectObject(null);
const activeCameraId = useDirectorStore.getState().project.activeCameraId!;
usePlaybackStore.getState().setPlayhead(1);
render(<TimelinePanel />);
expect(screen.getByRole("button", { name: /在指针处打关键帧/ })).toBeDisabled();
const addButton = screen.getByRole("button", { name: /在指针处打关键帧/ });
expect(addButton).toBeEnabled();
fireEvent.click(addButton);
const track = useDirectorStore
.getState()
.project.timeline.cameraTracks.find((t) => t.cameraId === activeCameraId);
expect(track?.keyframes).toHaveLength(1);
});
it("adds camera keyframes for multiple cameras independently", () => {
const secondId = useDirectorStore.getState().addCameraShot();
// 新增机位会被设为当前激活机位
expect(useDirectorStore.getState().project.activeCameraId).toBe(secondId);
usePlaybackStore.getState().setPlayhead(2);
render(<TimelinePanel />);
// 给第二个机位打关键帧
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
// 切回第一个机位再打一个关键帧
const firstId = useDirectorStore.getState().project.cameras[0].id;
act(() => {
useDirectorStore.getState().setActiveCamera(firstId);
usePlaybackStore.getState().setPlayhead(3);
});
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
const tracks = useDirectorStore.getState().project.timeline.cameraTracks;
expect(tracks.find((t) => t.cameraId === secondId)?.keyframes).toHaveLength(1);
expect(tracks.find((t) => t.cameraId === firstId)?.keyframes).toHaveLength(1);
});
it("adds an object keyframe for the selected object at the playhead", () => {

View File

@@ -8,6 +8,7 @@ import type {
import {
Box,
Camera,
Check,
ChevronDown,
ChevronRight,
ChevronUp,
@@ -46,7 +47,7 @@ import {
type CameraKeyframeValue,
type KeyframeTransformValue,
} from "../schema/timeline";
import type { MotionClip, MotionKind } from "../schema/directorProject";
import type { DirectorCameraShot, MotionClip, MotionKind } from "../schema/directorProject";
import {
TRAJECTORY_POINT_MIN_GAP,
getMotionEndTime,
@@ -512,15 +513,16 @@ export function TimelinePanel() {
const timeline = project.timeline;
// 选中目标解析:物体 or 相机
// 选中目标解析:物体 / 相机
const selectedObject = project.objects.find((o) => o.id === selectedObjectId) ?? null;
const selectedCameraObject =
selectedObject?.kind === "camera" ? selectedObject : null;
const selectedShot = selectedCameraObject?.linkedCameraId
? project.cameras.find((c) => c.id === selectedCameraObject.linkedCameraId) ?? null
: null;
const canAddCameraKeyframe = Boolean(selectedShot && selectedShot.targetMode === "manual");
const selectionIsManualCamera = Boolean(selectedCameraObject);
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");
@@ -530,19 +532,18 @@ export function TimelinePanel() {
scale: [...selectedObject.transform.scale] as [number, number, number],
};
}
function snapshotShot(): CameraKeyframeValue {
if (!selectedShot) throw new Error("no selected shot");
return {
position: [...selectedShot.transform.position] as [number, number, number],
lookRotation: [...selectedShot.lookRotation] as [number, number, number],
fov: selectedShot.fov,
};
}
function handleAddKeyframe() {
if (selectionIsManualCamera) {
if (!selectedShot || selectedShot.targetMode !== "manual") return;
addCameraKeyframe(selectedShot.id, playhead, snapshotShot());
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 关键帧轨道,
// 否则同一个角色会同时出现「物体轨道」和「角色轨道」两条。
@@ -1469,10 +1470,10 @@ export function TimelinePanel() {
togglePlaying();
}
const addKeyframeDisabled = selectionIsManualCamera ? !canAddCameraKeyframe : !selectedObject;
const addKeyframeTitle = selectionIsManualCamera
const addKeyframeDisabled = addTargetsCamera ? !canAddCameraKeyframe : !selectedObject;
const addKeyframeTitle = addTargetsCamera
? canAddCameraKeyframe
? `把相机「${selectedShot?.name}」当前机位记录为 ${formatTime(playhead)} 处的运镜关键帧(快捷键 K)`
? `把相机「${activeCamera?.name}」当前机位记录为 ${formatTime(playhead)} 处的运镜关键帧(快捷键 K)`
: "锁定物体的相机(targetMode=object)不支持运镜关键帧"
: selectedObject
? `把「${selectedObject.name}」当前位置记录为 ${formatTime(playhead)} 处的关键帧(快捷键 K)`
@@ -1557,6 +1558,15 @@ export function TimelinePanel() {
</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">
@@ -2330,3 +2340,102 @@ export function TimelinePanel() {
</section>
);
}
/**
* 时间轴工具栏的「当前机位」选择器:自定义下拉(非原生 <select>),
* 这样深色主题下选项文字也能正常显示,并跟随主题配色。
* 展开后列出全部机位,当前机位高亮并打勾;点击外部 / Esc 关闭。
*/
function TimelineCameraSelect({
cameras,
activeCameraId,
onSelect,
}: {
cameras: DirectorCameraShot[];
activeCameraId: string | null;
onSelect: (id: string) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const activeCamera = cameras.find((camera) => camera.id === activeCameraId) ?? null;
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: globalThis.MouseEvent) => {
if (!dropdownRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
const selectCamera = (id: string) => {
onSelect(id);
setIsOpen(false);
};
return (
<div className="timeline-camera-select" ref={dropdownRef}>
<button
aria-label="当前机位"
aria-haspopup="listbox"
aria-expanded={isOpen}
className="timeline-camera-select-trigger"
type="button"
onClick={() => setIsOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setIsOpen(true);
}
}}
>
<span className="timeline-camera-select-value">
{activeCamera?.name ?? "未选择机位"}
</span>
<ChevronDown
aria-hidden="true"
className="timeline-camera-select-chevron"
size={13}
strokeWidth={2.2}
/>
</button>
{isOpen ? (
<div className="timeline-camera-select-menu" role="listbox" aria-label="选择机位">
{cameras.map((camera) => {
const isSelected = camera.id === activeCameraId;
return (
<button
aria-selected={isSelected}
className={`timeline-camera-select-option${isSelected ? " is-selected" : ""}`}
key={camera.id}
role="option"
type="button"
onClick={() => selectCamera(camera.id)}
>
<span>{camera.name}</span>
{isSelected ? (
<Check aria-hidden="true" size={13} strokeWidth={2.4} />
) : null}
</button>
);
})}
</div>
) : null}
</div>
);
}

View File

@@ -3,29 +3,74 @@ import { PerspectiveCamera as ThreePerspectiveCamera } from "three";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "./playbackStore";
import { applyActiveCameraViewAtTime } from "./playbackTransforms";
import { getActiveCameraIdAtTime } from "../schema/timeline";
import {
getCameraRenderQuaternion,
getCameraViewSnapshotFromShot,
getWorldCameraViewSnapshot,
} from "../schema/cameraGeometry";
/**
* 机位视图下:若当前激活相机有运镜轨道,每帧用插值结果覆盖渲染相机的
* position / lookAt(target) / fov,呈现真正的运镜画面(逻辑在 playbackTransforms.ts,
* 与视频导出共用)。挂在 <Canvas> 内;非机位视图或相机无轨道时为空操作。
* 机位视图下:
* - 播放 / 导出时按关键帧时间自动切机位:当前应显示的机位由所有相机轨道里「最近一帧 ≤ 播放头」
* 时间最大者决定(切点即关键帧时间点)。导出时虽然没在播放,也要让屏幕预览跟着切,
* 否则导出的画面在切、预览却在原地(预览与成片对不上)。
* - 非播放(编辑 / scrub)保持手动选中的机位,避免和「选中某机位编辑其关键帧」打架。
* - 始终用「当前应显示的机位」的插值结果覆盖渲染相机的 position / lookAt / fov(逻辑在
* playbackTransforms.ts,与视频导出共用)。挂在 <Canvas> 内;导演视角下为空操作。
*/
export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: string | null }) {
const camera = useThree((state) => state.camera);
const camera = useThree((state) => state.camera) as ThreePerspectiveCamera;
const cameraTracks = useDirectorStore((state) => state.project.timeline.cameraTracks);
const cameras = useDirectorStore((state) => state.project.cameras);
const projectScene = useDirectorStore((state) => state.project.scene);
const viewMode = useDirectorStore((state) => state.viewMode);
const setActiveCamera = useDirectorStore((state) => state.setActiveCamera);
const playhead = usePlaybackStore((state) => state.playhead);
const editingObjectId = usePlaybackStore((state) => state.editingObjectId);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const isExporting = usePlaybackStore((state) => state.isExporting);
useFrame(() => {
// 机位视角下覆盖渲染相机(播放 + scrub 均生效),导演视角 / 编辑中不覆盖
applyActiveCameraViewAtTime({
camera: camera as ThreePerspectiveCamera,
if (viewMode !== "camera") return;
// 播放 / 导出时按关键帧自动切机位;否则沿用手动选中的机位
const autoCut = isPlaying || isExporting;
const displayCameraId = autoCut
? getActiveCameraIdAtTime(cameraTracks, activeCameraId, playhead)
: activeCameraId;
// 仅播放且切点变化时把切换写回工程,让工具条 / 导出都跟随当前机位;
// 导出时不写回:导出是一次性跑完,无需同步 UI,也避免每帧触发 React 重渲染换相机。
if (isPlaying && displayCameraId && displayCameraId !== activeCameraId) {
setActiveCamera(displayCameraId);
}
// 机位视角下覆盖渲染相机(播放 + scrub + 导出预览均生效),导演视角 / 编辑中不覆盖
const appliedCameraTrack = applyActiveCameraViewAtTime({
camera,
cameraTracks,
activeCameraId,
activeCameraId: displayCameraId,
playhead,
viewMode,
editingObjectId,
});
// 该机位没有运镜轨道时,把机位「自身视角」直接写进渲染相机:
// 否则画面取决于默认相机是谁,会渲成导演视角(更高、更远)。播放 / 导出预览都要兜底,
// 保证屏幕预览和逐帧导出的成片一致。
if (!appliedCameraTrack && autoCut && displayCameraId) {
const shot = cameras.find((item) => item.id === displayCameraId) ?? null;
if (shot) {
const worldView = getWorldCameraViewSnapshot(getCameraViewSnapshotFromShot(shot), projectScene);
camera.position.set(worldView.position[0], worldView.position[1], worldView.position[2]);
camera.quaternion.copy(getCameraRenderQuaternion(worldView.lookRotation));
if (Math.abs(camera.fov - worldView.fov) > 0.001) {
camera.fov = worldView.fov;
}
camera.updateProjectionMatrix();
}
}
});
return null;

View File

@@ -7,6 +7,8 @@ export interface PlaybackState {
duration: number; // 秒,与 timeline.duration 保持同步
/** 播放到末尾是否循环回起点(与 timeline.loop 保持同步) */
loop: boolean;
/** 是否正在导出视频:导出预览需要按时间轴自动切机位(与播放时一致),即便此刻并未播放 */
isExporting: boolean;
/** 正在被 gizmo 拖拽的对象 id(DirectorObject.id 或 DirectorCameraShot.id)。
* PlaybackTransformSync 会跳过它,避免把 gizmo 改的值立刻覆盖回去。 */
editingObjectId: string | null;
@@ -19,6 +21,7 @@ export interface PlaybackState {
setDuration: (duration: number) => void;
setLoop: (loop: boolean) => void;
setEditingObjectId: (id: string | null) => void;
setExporting: (exporting: boolean) => void;
/** 供 useFrame 调用:推进 playhead;到末尾时按 loop 决定「循环回起点」还是「停住」。 */
advance: (deltaSeconds: number) => void;
}
@@ -39,6 +42,7 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
playhead: 0,
duration: DEFAULT_TIMELINE_DURATION,
loop: false,
isExporting: false,
editingObjectId: null,
setPlaying: (isPlaying) => set({ isPlaying }),
togglePlaying: () =>
@@ -57,6 +61,7 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
setDuration: (duration) => set({ duration: Math.max(0.1, duration) }),
setLoop: (loop) => set({ loop }),
setEditingObjectId: (editingObjectId) => set({ editingObjectId }),
setExporting: (isExporting) => set({ isExporting }),
advance: (deltaSeconds) => {
if (!get().isPlaying) return;
set((state) => {

View File

@@ -5,6 +5,18 @@ import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline";
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "./playbackTransforms";
import { computeExportSize } from "../io/videoExport";
import { getCameraLookQuaternion } from "../schema/cameraGeometry";
import { getActiveCameraIdAtTime } from "../schema/timeline";
function makeCameraTrackAt(cameraId: string, times: number[]): CameraTimelineTrack {
return {
cameraId,
keyframes: times.map((time, index) => ({
id: `${cameraId}-${index}`,
time,
value: { position: [0, 0, 0], lookRotation: [0, 0, 0], fov: 50 },
})),
};
}
function makeTrajectoryCharacter(): DirectorObject {
return {
@@ -182,6 +194,45 @@ it("does not override the render camera in director view mode", () => {
expect(camera.position.x).toBe(100);
});
describe("getActiveCameraIdAtTime", () => {
// camA 在 1s / 5s 打点,camB 在 3s / 7s 打点:播放时应按「最近一帧 ≤ 播放头」切机位
const tracks = [makeCameraTrackAt("camA", [1, 5]), makeCameraTrackAt("camB", [3, 7])];
it("falls back before any camera has a keyframe", () => {
expect(getActiveCameraIdAtTime(tracks, "camA", 0)).toBe("camA");
});
it("uses the only camera that has a keyframe", () => {
// t=2:camA 最近帧 1s,camB 还没有 → camA
expect(getActiveCameraIdAtTime(tracks, "camB", 2)).toBe("camA");
});
it("cuts to the camera with the most recent keyframe", () => {
// t=4:camA 1s、camB 3s → camB(3 > 1)
expect(getActiveCameraIdAtTime(tracks, "camB", 4)).toBe("camB");
// t=6:camA 5s、camB 3s → camA(5 > 3)
expect(getActiveCameraIdAtTime(tracks, "camA", 6)).toBe("camA");
// t=8:camB 7s 领先 → camB
expect(getActiveCameraIdAtTime(tracks, "camB", 8)).toBe("camB");
});
it("keeps a single camera on screen the whole time", () => {
const single = [makeCameraTrackAt("camA", [0, 2, 4])];
expect(getActiveCameraIdAtTime(single, "camA", 0)).toBe("camA");
expect(getActiveCameraIdAtTime(single, "camA", 3)).toBe("camA");
expect(getActiveCameraIdAtTime(single, "camA", 9)).toBe("camA");
});
it("falls back when no track has keyframes", () => {
expect(getActiveCameraIdAtTime([makeCameraTrackAt("camA", [])], "camB", 5)).toBe("camB");
});
it("breaks ties by camera track order (first wins)", () => {
const tied = [makeCameraTrackAt("camA", [3]), makeCameraTrackAt("camB", [3])];
expect(getActiveCameraIdAtTime(tied, null, 3)).toBe("camA");
});
});
describe("computeExportSize", () => {
it("uses 16:9 short-edge-720 for a 16:9 ratio", () => {
expect(computeExportSize("16:9")).toEqual({ width: 1280, height: 720 });

View File

@@ -188,6 +188,38 @@ export function evaluateCameraTrackAtTime(track: CameraTimelineTrack, time: numb
return evaluateKeyframes(track.keyframes, time, lerpCameraValue);
}
/**
* 某个时刻应该显示哪个机位(多机位自动切换 / 自动切点):
* 取所有相机轨道里「时间 ≤ t 的最后一帧」中时间最大者所属的那个机位。
* 没有任何机位在 t 之前打过关键帧时,退回 fallback(通常是当前激活机位)。
* 这样时间轴播放时,机位视角会按关键帧时间点自动切到对应机位。
*/
export function getActiveCameraIdAtTime(
cameraTracks: CameraTimelineTrack[],
fallbackCameraId: string | null,
time: number
): string | null {
let bestCameraId: string | null = null;
let bestTime = -Infinity;
for (const track of cameraTracks) {
if (track.keyframes.length === 0) continue;
// 取该机位在 t 之前(含)最近的一帧
let latest = -Infinity;
for (const keyframe of track.keyframes) {
if (keyframe.time <= time && keyframe.time > latest) latest = keyframe.time;
}
if (latest > bestTime) {
bestTime = latest;
bestCameraId = track.cameraId;
}
}
return bestCameraId ?? fallbackCameraId;
}
// ===== 物体增删改 =====
export function upsertKeyframe(
timeline: Timeline,

View File

@@ -3760,6 +3760,125 @@ textarea:focus-visible {
.timeline-duration-spinner-button svg {
color: inherit;
}
/* 工具栏「当前机位」选择器:和总时长 chip 同高的胶囊,显示并可切换正在编辑的机位 */
.timeline-camera-selector {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 8px;
border: 1px solid rgb(var(--border-rgb) / 0.35);
border-radius: 8px;
color: rgb(var(--text-muted-rgb));
background: rgb(var(--field-rgb));
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.timeline-camera-selector svg {
color: rgb(var(--text-dim-rgb));
}
.timeline-camera-selector-caption {
color: rgb(var(--text-dim-rgb));
font-size: 11px;
font-weight: 600;
}
/* 自定义机位下拉(非原生 select):外层做定位锚点 */
.timeline-camera-select {
position: relative;
display: inline-flex;
}
.timeline-camera-select-trigger {
appearance: none;
-webkit-appearance: none;
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 0;
margin: 0;
border: 0;
background: transparent;
color: rgb(var(--text-rgb));
font: inherit;
font-weight: 600;
cursor: pointer;
outline: none;
}
.timeline-camera-select-trigger:focus-visible {
box-shadow: 0 0 0 1px rgb(var(--accent-rgb) / 0.5);
border-radius: 4px;
}
.timeline-camera-select-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-camera-select-chevron {
flex-shrink: 0;
color: rgb(var(--text-dim-rgb));
transition: transform 0.16s ease;
}
.timeline-camera-select-trigger[aria-expanded="true"] .timeline-camera-select-chevron {
transform: rotate(180deg);
}
/* 下拉面板:跟随主题配色,深色下选项文字正常可见 */
.timeline-camera-select-menu {
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 60;
display: grid;
gap: 4px;
width: max-content;
min-width: 160px;
max-width: 240px;
max-height: 280px;
overflow-y: auto;
padding: 4px;
border: 1px solid rgb(var(--border-rgb) / 0.4);
border-radius: 8px;
background: rgb(var(--panel-rgb) / 0.96);
box-shadow: var(--ui-shadow-panel);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
}
.timeline-camera-select-option {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-height: 32px;
border: 0;
border-radius: 8px;
padding: 6px 10px;
background: transparent;
color: rgb(var(--text-muted-rgb));
box-shadow: none;
font-size: 12px;
font-weight: 500;
line-height: 17px;
text-align: left;
cursor: pointer;
transition:
background-color 0.16s ease,
color 0.16s ease;
}
.timeline-camera-select-option:hover,
.timeline-camera-select-option:focus-visible {
color: rgb(var(--text-rgb));
background: rgb(var(--text-rgb) / 0.05);
}
.timeline-camera-select-option.is-selected {
color: rgb(var(--accent-rgb));
}
.timeline-camera-select-option span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 时间轴设置弹窗:遮罩 + 卡片。草稿值只在点「完成」时才写回工程 */
.timeline-settings-overlay {