- {poseGroups.map((group) => (
-
- {group.title}
- {group.controls.map((control) => (
-
- isCrowd && selection.crowdId
- ? updateCrowdPoseControl(selection.crowdId, control.key, Number(value))
- : updatePoseControl(role.id, control.key, Number(value))
- }
- />
- ))}
-
+
+ {poseSections.map((section) => (
+
+ {section.title}
+
+ {section.groups.map((group) => (
+
+ {group.title}
+ {group.controls.map((control) => {
+ const motionDeg = motionDegrees[control.key] ?? 0;
+ const offset = role.characterRig?.controls[control.key] ?? 0;
+ const displayValue = Math.round(Math.min(90, Math.max(-90, motionDeg + offset)));
+ return (
+ {
+ // 滑杆值 = 动作当前值 + 偏移 → 回写时反算偏移
+ const nextOffset = Number(value) - motionDeg;
+ if (isCrowd && selection.crowdId) {
+ updateCrowdPoseControl(selection.crowdId, control.key, nextOffset);
+ return;
+ }
+ updatePoseControl(role.id, control.key, nextOffset);
+ }}
+ />
+ );
+ })}
+
+ ))}
+
+
))}
+ {isMotionActive ? (
+
+ 滑杆显示「动作当前值 + 偏移」,随时间轴指针实时更新;拖动滑杆即设置该关节偏移。应用姿势预设会关闭动作并整身替换。
+
+ ) : null}
>
) : (
该模型未识别到标准 humanoid 骨骼,暂不支持姿势编辑。
)}
+ ) : (
+
+ {role.characterRig ? (
+ <>
+
+ {
+ const next = Number(value);
+ if (isCrowd && selection.crowdId) {
+ setCrowdMotionSpeed(selection.crowdId, next);
+ return;
+ }
+ setMotionSpeed(role.id, next);
+ }}
+ />
+ {
+ const next = Number(value);
+ if (isCrowd && selection.crowdId) {
+ setCrowdMotionAmplitude(selection.crowdId, next);
+ return;
+ }
+ setMotionAmplitude(role.id, next);
+ }}
+ />
+ {
+ if (isCrowd && selection.crowdId) {
+ setCrowdMotionEnabled(selection.crowdId, checked);
+ return;
+ }
+ setMotionEnabled(role.id, checked);
+ }}
+ />
+ {motion.kind === "idle" ? (
+ 闲置动作:当前仅作为配置项保留,关节驱动在下一阶段接入。
+ ) : motion.kind === "run" ? (
+ 跑步动作:切换动作时姿势已重置为站立基础态;可在「姿势」标签对各部位叠加偏移微调。
+ ) : motion.kind === "walk" ? (
+ 走路动作:切换动作时姿势已重置为站立基础态;可在「姿势」标签对各部位叠加偏移微调。
+ ) : null}
+ >
+ ) : (
+ 该模型未识别到标准 humanoid 骨骼,暂不支持基础动作。
+ )}
+
)}
);
diff --git a/src/editor/panels/InspectorControls.tsx b/src/editor/panels/InspectorControls.tsx
index aa74b36..8a08e8a 100644
--- a/src/editor/panels/InspectorControls.tsx
+++ b/src/editor/panels/InspectorControls.tsx
@@ -53,6 +53,7 @@ type RangeNumberFieldProps = {
min: string | number;
max: string | number;
step: string | number;
+ disabled?: boolean;
};
type InspectorSelectOption = {
@@ -437,6 +438,7 @@ export function InspectorRangeNumberField({
min,
max,
step,
+ disabled,
}: RangeNumberFieldProps) {
const rangeDragCleanupRef = useRef<(() => void) | null>(null);
const { beginInteraction, endInteraction } = useUndoBatchInteraction();
@@ -459,12 +461,13 @@ export function InspectorRangeNumberField({
}
return (
-
+
{label}
);
}
+
+export function InspectorCheckboxField({
+ label,
+ ariaLabel,
+ checked,
+ onChange,
+ disabled,
+}: {
+ label: string;
+ ariaLabel: string;
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ disabled?: boolean;
+}) {
+ const { beginInteraction, endInteraction } = useUndoBatchInteraction();
+
+ return (
+
+ );
+}
diff --git a/src/editor/panels/TimelinePanel.test.tsx b/src/editor/panels/TimelinePanel.test.tsx
index 93d02b4..0530d53 100644
--- a/src/editor/panels/TimelinePanel.test.tsx
+++ b/src/editor/panels/TimelinePanel.test.tsx
@@ -91,3 +91,40 @@ it("clears all keyframes via the clear-all button", () => {
expect(useDirectorStore.getState().project.timeline.tracks).toEqual([]);
});
+
+it("grows the timeline panel when dragging the top resizer upward", () => {
+ const { container } = render(
);
+ const panel = container.querySelector
(".timeline-panel")!;
+ expect(panel.style.height).toBe("300px");
+
+ const resizer = screen.getByRole("separator", { name: "拖动调整时间轴高度" });
+ fireEvent.pointerDown(resizer, { clientY: 500, pointerId: 1 });
+ fireEvent.pointerMove(resizer, { clientY: 440, pointerId: 1 }); // 向上拖 60px
+ fireEvent.pointerUp(resizer, { clientY: 440, pointerId: 1 });
+
+ expect(panel.style.height).toBe("360px");
+});
+
+it("clamps the timeline height to a minimum when dragging downward far", () => {
+ const { container } = render();
+ const panel = container.querySelector(".timeline-panel")!;
+
+ const resizer = screen.getByRole("separator", { name: "拖动调整时间轴高度" });
+ fireEvent.pointerDown(resizer, { clientY: 500, pointerId: 1 });
+ fireEvent.pointerMove(resizer, { clientY: 900, pointerId: 1 }); // 向下拖 400px
+ fireEvent.pointerUp(resizer, { clientY: 900, pointerId: 1 });
+
+ expect(panel.style.height).toBe("160px");
+});
+
+it("clamps the timeline height to the fixed 420px maximum when dragging upward far", () => {
+ const { container } = render();
+ const panel = container.querySelector(".timeline-panel")!;
+
+ const resizer = screen.getByRole("separator", { name: "拖动调整时间轴高度" });
+ fireEvent.pointerDown(resizer, { clientY: 500, pointerId: 1 });
+ fireEvent.pointerMove(resizer, { clientY: 100, pointerId: 1 }); // 向上拖 400px → 700 超上限
+ fireEvent.pointerUp(resizer, { clientY: 100, pointerId: 1 });
+
+ expect(panel.style.height).toBe("420px");
+});
diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx
index c2e7e86..9e13728 100644
--- a/src/editor/panels/TimelinePanel.tsx
+++ b/src/editor/panels/TimelinePanel.tsx
@@ -1,4 +1,5 @@
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
+import type { PointerEvent as ReactPointerEvent } from "react";
import type { CameraKeyframeValue, KeyframeTransformValue } from "../schema/timeline";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
@@ -10,6 +11,27 @@ interface SelectedKeyframe {
keyframeId: string;
}
+const TIMELINE_MIN_HEIGHT = 160;
+const TIMELINE_DEFAULT_HEIGHT = 300;
+/** 面板最大高度固定值:内容再多也在面板内部滚动,不把 3D 视口压没 */
+const TIMELINE_MAX_HEIGHT = 420;
+const TIMELINE_HEIGHT_STORAGE_KEY = "director.timeline.height";
+
+function clampTimelineHeight(height: number) {
+ return Math.min(Math.max(Math.round(height), TIMELINE_MIN_HEIGHT), TIMELINE_MAX_HEIGHT);
+}
+
+function loadTimelineHeight() {
+ try {
+ const raw = window.localStorage.getItem(TIMELINE_HEIGHT_STORAGE_KEY);
+ if (!raw) return TIMELINE_DEFAULT_HEIGHT;
+ const value = Number(raw);
+ return Number.isFinite(value) ? clampTimelineHeight(value) : TIMELINE_DEFAULT_HEIGHT;
+ } catch {
+ return TIMELINE_DEFAULT_HEIGHT;
+ }
+}
+
function formatTime(t: number) {
return `${t.toFixed(1)}s`;
}
@@ -39,6 +61,52 @@ export function TimelinePanel() {
const [selectedKeyframe, setSelectedKeyframe] = useState(null);
const [isRecording, setIsRecording] = useState(false);
+ // 时间轴面板高度:可拖动顶部手柄调整(向上拖变大),并持久化到 localStorage
+ const [timelineHeight, setTimelineHeight] = useState(loadTimelineHeight);
+ const [isResizing, setIsResizing] = useState(false);
+ const resizeStartRef = useRef<{ pointerY: number; startHeight: number } | null>(null);
+ const timelineHeightRef = useRef(timelineHeight);
+ timelineHeightRef.current = timelineHeight;
+
+ useEffect(() => {
+ if (!isResizing) return;
+ document.body.classList.add("is-resizing-timeline");
+ return () => document.body.classList.remove("is-resizing-timeline");
+ }, [isResizing]);
+
+ function handleResizeStart(event: ReactPointerEvent) {
+ event.preventDefault();
+ resizeStartRef.current = { pointerY: event.clientY, startHeight: timelineHeight };
+ setIsResizing(true);
+ try {
+ event.currentTarget.setPointerCapture(event.pointerId);
+ } catch {
+ // 某些环境(如 jsdom)不支持 pointer capture,忽略后仍可通过 move/up 事件拖拽
+ }
+ }
+
+ function handleResizeMove(event: ReactPointerEvent) {
+ const start = resizeStartRef.current;
+ if (!start) return;
+ setTimelineHeight(clampTimelineHeight(start.startHeight + (start.pointerY - event.clientY)));
+ }
+
+ function handleResizeEnd(event: ReactPointerEvent) {
+ if (!resizeStartRef.current) return;
+ resizeStartRef.current = null;
+ setIsResizing(false);
+ try {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ } catch {
+ // 同上,忽略不支持的环境
+ }
+ try {
+ window.localStorage.setItem(TIMELINE_HEIGHT_STORAGE_KEY, String(timelineHeightRef.current));
+ } catch {
+ // 持久化失败(如隐私模式)不影响功能
+ }
+ }
+
const timeline = project.timeline;
// 选中目标解析:物体 or 相机
@@ -138,7 +206,22 @@ export function TimelinePanel() {
: "请先选择一个对象或相机";
return (
-
+
+
diff --git a/src/editor/runtime/ActiveCameraPlaybackSync.tsx b/src/editor/runtime/ActiveCameraPlaybackSync.tsx
index 041721c..de57c04 100644
--- a/src/editor/runtime/ActiveCameraPlaybackSync.tsx
+++ b/src/editor/runtime/ActiveCameraPlaybackSync.tsx
@@ -1,13 +1,13 @@
import { useFrame, useThree } from "@react-three/fiber";
import { PerspectiveCamera as ThreePerspectiveCamera } from "three";
import { useDirectorStore } from "../store/directorStore";
-import { evaluateCameraTrackAtTime } from "../schema/timeline";
import { usePlaybackStore } from "./playbackStore";
+import { applyActiveCameraViewAtTime } from "./playbackTransforms";
/**
* 机位视图下:若当前激活相机有运镜轨道,每帧用插值结果覆盖渲染相机的
- * position / lookAt(target) / fov,呈现真正的运镜画面。
- * 挂在