feat(时间轴): 支持多机位自动切换与关键帧
- 新增 getActiveCameraIdAtTime,按时间轴关键帧自动选择机位 - 导出与播放时预览按切点自动切机位,保持一致 - 时间轴工具栏新增当前机位选择器 - 无选中物体时给当前机位打关键帧,支持多机位独立打点
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user