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,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>
);
}