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