diff --git a/docs/plans/2026-09-05-invert-camera-mouse-y.md b/docs/plans/2026-09-05-invert-camera-mouse-y.md
new file mode 100644
index 0000000..0b1da14
--- /dev/null
+++ b/docs/plans/2026-09-05-invert-camera-mouse-y.md
@@ -0,0 +1,101 @@
+# Invert Camera Mouse Y Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use `executing-plans` to implement this plan task-by-task.
+
+**Goal:** Add a session-scoped, default-off mouse vertical inversion setting for keyboard-and-mouse camera control without affecting horizontal look or keyboard rotation.
+
+**Architecture:** Extend the existing normalized `CameraDriveSettings` object so the new boolean follows the same lifetime as speed, step, and sensitivity. Apply the sign when pointer Y input is queued, cancel queued look when the value changes, and make the viewport release an active right-button gesture at that boundary. Expose the value as an accessible checkbox in the existing camera-control strip.
+
+**Tech Stack:** React 19, TypeScript, Three.js, Vitest, Testing Library, Playwright.
+
+---
+
+### Task 1: Core setting and input behavior
+
+**Files:**
+- Modify: `packages/studio/test/camera-drive.test.ts`
+- Modify: `packages/studio/src/components/editor/camera-drive.ts`
+
+**Step 1: Write the failing tests**
+
+Add tests that construct normal and inverted drives, send the same `look(40, 20)` input, and assert equal yaw with opposite pitch. Add a boundary test that queues pointer look, changes only `invertMouseY`, and verifies no queued rotation is applied while an already-held keyboard movement key remains active.
+
+**Step 2: Run tests to verify they fail**
+
+Run: `npm test -w @lumora/studio -- camera-drive.test.ts`
+
+Expected: FAIL because `CameraDriveSettings` has no `invertMouseY` field and inversion is not applied.
+
+**Step 3: Write the minimal implementation**
+
+Add `invertMouseY: boolean`, default it to `false`, normalize only explicit booleans, and queue vertical look as:
+
+```ts
+const verticalDelta = this.settings.invertMouseY ? -deltaY : deltaY;
+this.lookDelta.y = THREE.MathUtils.clamp(
+ this.lookDelta.y + verticalDelta,
+ -MAX_LOOK_DELTA,
+ MAX_LOOK_DELTA,
+);
+```
+
+When `setSettings` changes inversion without changing mode, call `cancelLook()` rather than clearing keyboard input.
+
+**Step 4: Run tests to verify they pass**
+
+Run: `npm test -w @lumora/studio -- camera-drive.test.ts`
+
+Expected: PASS.
+
+### Task 2: Session state, UI, and gesture boundary
+
+**Files:**
+- Modify: `packages/studio/test/use-timeline-session.test.tsx`
+- Modify: `packages/studio/test/timeline-panel.test.tsx`
+- Modify: `packages/studio/test/camera-drive-routing.test.tsx`
+- Modify: `packages/studio/src/components/editor/TimelinePanel.tsx`
+- Modify: `packages/studio/src/components/editor/EditorViewport.tsx`
+- Modify: `packages/studio/src/lumora.css`
+
+**Step 1: Write the failing tests**
+
+Assert the session default is `false`, explicit `true` survives a project switch, explicit `false` is accepted, and a remounted session returns to the shared default. Assert the panel checkbox exposes its checked/unchecked state and calls `setCameraControlSettings({ invertMouseY: ... })`. Add a viewport routing test that changes inversion during an active right-button gesture and proves stale pointer movement is ignored until a fresh pointerdown.
+
+**Step 2: Run tests to verify they fail**
+
+Run: `npm test -w @lumora/studio -- use-timeline-session.test.tsx timeline-panel.test.tsx camera-drive-routing.test.tsx`
+
+Expected: FAIL because the checkbox and inversion gesture boundary do not exist.
+
+**Step 3: Write the minimal implementation**
+
+Render a checkbox labeled `垂直反转` and pass its checked value to the session setter. Keep it configurable in keyboard-only mode so users can prepare the setting before switching modes; the value only affects keyboard-mouse pointer look. In the viewport loop compare the previous and current `invertMouseY`; on change call `drive.cancelLook()` and `endLookGesture()` so the next drag requires a new pointerdown. Add compact checkbox sizing that participates in the existing responsive flex layout.
+
+**Step 4: Run tests to verify they pass**
+
+Run: `npm test -w @lumora/studio -- use-timeline-session.test.tsx timeline-panel.test.tsx camera-drive-routing.test.tsx`
+
+Expected: PASS.
+
+### Task 3: Browser regression and delivery verification
+
+**Files:**
+- Modify: `e2e/timeline.spec.ts`
+
+**Step 1: Write the failing browser regression**
+
+Exercise normal drag, inverted drag, mid-gesture toggle, fresh drag, explicit disable, and page reload. Assert pitch direction reverses, heading/keyboard behavior remains unchanged, stale movement after the toggle does not mutate the camera, and reload restores the default-off state.
+
+**Step 2: Run the browser test to verify it fails**
+
+Run: `npx playwright test e2e/timeline.spec.ts --project=chromium --grep "vertical inversion"`
+
+Expected: FAIL because the new control is absent.
+
+**Step 3: Complete the regression and visual checks**
+
+Run focused tests, `npm run typecheck`, `npm run lint`, `npm test`, `npm run build`, and the focused Chromium Playwright test. Capture desktop and mobile screenshots and inspect the control for clear state, overflow, and overlap.
+
+**Step 4: Deliver through GitHub**
+
+Review the scoped diff, commit the implementation and tests, push the task branch, create one draft PR targeting `main` with `task_id: TML-813` and `run_id: 1443ad39-7c9e-4c7a-b3a2-1f8bc5959f24`, then read the PR title/body back for metadata verification.
diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts
index 8684d24..7df5062 100644
--- a/e2e/timeline.spec.ts
+++ b/e2e/timeline.spec.ts
@@ -401,6 +401,76 @@ test('real right-drag drives only an unblocked POV and suppresses complete gestu
expect(quaternionAngle(outOfViewportAfter.rotation, outOfViewportBefore.rotation)).toBeGreaterThan(0.001);
});
+test('mouse vertical inversion reverses only pitch and resets an active gesture', async ({ page }) => {
+ await page.getByTestId('tree-row-sample-camera').click();
+ await page.getByTestId('view-mode-select').selectOption('sample-camera');
+ await page.getByTestId('track-disabled-sample-track-camera-dolly').check();
+ await page.getByTestId('track-disabled-sample-track-camera-focus').check();
+ await expect(page.getByTestId('camera-control-status')).toHaveText('机位“主摄像机”可手动操控。');
+
+ const viewport = page.getByTestId('lumora-viewport');
+ const bounds = await viewport.boundingBox();
+ if (!bounds) throw new Error('viewport is unavailable');
+ const startX = bounds.x + bounds.width * 0.5;
+ const startY = bounds.y + bounds.height * 0.45;
+ const invertMouseY = page.getByTestId('camera-control-invert-mouse-y');
+ await expect(invertMouseY).not.toBeChecked();
+
+ const dragVertically = async () => {
+ await page.mouse.move(startX, startY);
+ await page.mouse.down({ button: 'right' });
+ await page.mouse.move(startX, startY + 48, { steps: 6 });
+ await page.mouse.up({ button: 'right' });
+ return stableCameraPose(page);
+ };
+
+ const normalBefore = await stableCameraPose(page);
+ const normalAfter = await dragVertically();
+ const normalPitch = normalAfter.rotation[0] - normalBefore.rotation[0];
+ const normalYaw = normalAfter.rotation[1] - normalBefore.rotation[1];
+ expect(Math.abs(normalPitch)).toBeGreaterThan(0.01);
+ expect(Math.abs(normalYaw)).toBeLessThan(0.005);
+
+ await invertMouseY.check();
+ await expect(invertMouseY).toBeChecked();
+ const invertedBefore = await stableCameraPose(page);
+ const invertedAfter = await dragVertically();
+ const invertedPitch = invertedAfter.rotation[0] - invertedBefore.rotation[0];
+ const invertedYaw = invertedAfter.rotation[1] - invertedBefore.rotation[1];
+ expect(Math.abs(invertedPitch)).toBeGreaterThan(0.01);
+ expect(invertedPitch * normalPitch).toBeLessThan(0);
+ expect(Math.abs(invertedYaw)).toBeLessThan(0.005);
+
+ await invertMouseY.uncheck();
+ const restoredBefore = await stableCameraPose(page);
+ const restoredAfter = await dragVertically();
+ const restoredPitch = restoredAfter.rotation[0] - restoredBefore.rotation[0];
+ expect(restoredPitch * normalPitch).toBeGreaterThan(0);
+
+ await page.mouse.move(startX, startY);
+ await page.mouse.down({ button: 'right' });
+ await page.mouse.move(startX, startY + 20);
+ const poseAtToggle = await stableCameraPose(page);
+ await invertMouseY.evaluate((input) => (input as HTMLInputElement).click());
+ await expect(invertMouseY).toBeChecked();
+ await page.waitForTimeout(300);
+ const afterBoundary = await cameraPose(page);
+ expect(quaternionAngle(afterBoundary.rotation, poseAtToggle.rotation)).toBeLessThan(0.001);
+
+ await page.mouse.move(startX, startY + 70, { steps: 5 });
+ await page.waitForTimeout(300);
+ const afterStaleMove = await cameraPose(page);
+ expect(quaternionAngle(afterStaleMove.rotation, afterBoundary.rotation)).toBeLessThan(0.001);
+ await page.mouse.up({ button: 'right' });
+
+ const freshAfter = await dragVertically();
+ expect(quaternionAngle(freshAfter.rotation, afterStaleMove.rotation)).toBeGreaterThan(0.01);
+
+ await page.reload();
+ await page.getByTestId('open-sample-project').click();
+ await expect(page.getByTestId('camera-control-invert-mouse-y')).not.toBeChecked();
+});
+
test('suppresses an out-of-bounds release from an open ShadowRoot viewport', async ({ page }) => {
await page.getByTestId('tree-row-sample-camera').click();
await page.getByTestId('view-mode-select').selectOption('sample-camera');
diff --git a/packages/studio/src/components/editor/EditorViewport.tsx b/packages/studio/src/components/editor/EditorViewport.tsx
index 5919dba..9ea3e08 100644
--- a/packages/studio/src/components/editor/EditorViewport.tsx
+++ b/packages/studio/src/components/editor/EditorViewport.tsx
@@ -804,11 +804,15 @@ function useCameraDrive(
}
const st = sessionRef.current?.state;
if (st) {
- const previousMode = drive.getSettings().mode;
+ const previousSettings = drive.getSettings();
drive.setSettings(st.cameraControls);
- if (drive.getSettings().mode !== previousMode) {
+ const nextSettings = drive.getSettings();
+ if (nextSettings.mode !== previousSettings.mode) {
heldKeys.clear();
endLookGesture();
+ } else if (nextSettings.invertMouseY !== previousSettings.invertMouseY) {
+ drive.cancelLook();
+ endLookGesture();
}
}
// 可驾驶:选中机位 && 录制未暂停 && (暂停 || 录制中)&& 无启用轨道(录制中无视轨道;
diff --git a/packages/studio/src/components/editor/TimelinePanel.tsx b/packages/studio/src/components/editor/TimelinePanel.tsx
index e4ada6b..3c70069 100644
--- a/packages/studio/src/components/editor/TimelinePanel.tsx
+++ b/packages/studio/src/components/editor/TimelinePanel.tsx
@@ -481,6 +481,18 @@ export function TimelinePanel({
{state.cameraControls.mouseSensitivity.toFixed(1)}
+
): void {
const previousMode = this.settings.mode;
+ const previousInvertMouseY = this.settings.invertMouseY;
this.settings = normalizeCameraDriveSettings(settings, this.settings);
- if (this.settings.mode !== previousMode) this.clearMotion();
+ if (this.settings.mode !== previousMode) {
+ this.clearMotion();
+ } else if (this.settings.invertMouseY !== previousInvertMouseY) {
+ this.cancelLook();
+ }
}
acceptsKey(code: string): boolean {
@@ -511,7 +522,12 @@ export class CameraDrive {
!Number.isFinite(deltaY)
) return;
this.lookDelta.x = THREE.MathUtils.clamp(this.lookDelta.x + deltaX, -MAX_LOOK_DELTA, MAX_LOOK_DELTA);
- this.lookDelta.y = THREE.MathUtils.clamp(this.lookDelta.y + deltaY, -MAX_LOOK_DELTA, MAX_LOOK_DELTA);
+ const verticalDelta = this.settings.invertMouseY ? -deltaY : deltaY;
+ this.lookDelta.y = THREE.MathUtils.clamp(
+ this.lookDelta.y + verticalDelta,
+ -MAX_LOOK_DELTA,
+ MAX_LOOK_DELTA,
+ );
}
/** Clear queued pointer-look momentum without interrupting held keyboard input. */
diff --git a/packages/studio/src/lumora.css b/packages/studio/src/lumora.css
index 8b4cecb..093560c 100644
--- a/packages/studio/src/lumora.css
+++ b/packages/studio/src/lumora.css
@@ -1658,6 +1658,23 @@
opacity: 0.45;
}
+.lumora-camera-controls__invert {
+ flex: 0 0 auto;
+ font-size: 11px;
+}
+
+.lumora-camera-controls__invert input {
+ width: 14px;
+ height: 14px;
+ margin: 0;
+ accent-color: var(--lumora-accent);
+}
+
+.lumora-camera-controls__invert input:focus-visible {
+ outline: 2px solid var(--lumora-accent);
+ outline-offset: 1px;
+}
+
.lumora-camera-controls__value {
width: 28px;
color: var(--lumora-text);
diff --git a/packages/studio/test/camera-drive-routing.test.tsx b/packages/studio/test/camera-drive-routing.test.tsx
index 9d412e1..9016f0d 100644
--- a/packages/studio/test/camera-drive-routing.test.tsx
+++ b/packages/studio/test/camera-drive-routing.test.tsx
@@ -370,6 +370,35 @@ describe('camera drive keyboard routing', () => {
expect(renderedCamera.quaternion.angleTo(beforeQuaternion)).toBeGreaterThan(0.001);
});
+ it('requires a fresh right-button gesture after toggling mouse vertical inversion', async () => {
+ const studio = await mountStudio('lumora://drive-invert-mouse-y-boundary');
+ act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera']));
+ const viewport = within(studio.root).getByTestId('lumora-viewport');
+ const releasePointerCapture = vi.fn();
+ Object.assign(viewport, {
+ setPointerCapture: vi.fn(),
+ releasePointerCapture,
+ });
+ const look = vi.spyOn(CameraDrive.prototype, 'look');
+ await act(async () => delay(60));
+
+ fireEvent.pointerDown(viewport, { button: 2, buttons: 2, pointerId: 31, clientX: 100, clientY: 100 });
+ fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 31, clientX: 120, clientY: 110 });
+ expect(look).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(within(studio.root).getByRole('checkbox', { name: '鼠标垂直反转' }));
+ await act(async () => delay(40));
+ expect(releasePointerCapture).toHaveBeenCalledWith(31);
+ const callsAtToggle = look.mock.calls.length;
+
+ fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 31, clientX: 150, clientY: 140 });
+ expect(look).toHaveBeenCalledTimes(callsAtToggle);
+
+ fireEvent.pointerDown(viewport, { button: 2, buttons: 2, pointerId: 32, clientX: 150, clientY: 140 });
+ fireEvent.pointerMove(viewport, { buttons: 2, pointerId: 32, clientX: 170, clientY: 150 });
+ expect(look).toHaveBeenCalledTimes(callsAtToggle + 1);
+ });
+
it('keyboard-mouse mode keeps translation and pointer look independent', async () => {
const studio = await mountStudio('lumora://drive-independent-inputs');
act(() => studio.handle.current!.runtime.editor.setSelection(['sample-camera']));
diff --git a/packages/studio/test/camera-drive.test.ts b/packages/studio/test/camera-drive.test.ts
index fab6c0b..2e129ca 100644
--- a/packages/studio/test/camera-drive.test.ts
+++ b/packages/studio/test/camera-drive.test.ts
@@ -703,6 +703,58 @@ describe('CameraDrive:键鼠驾驶积分器', () => {
expect(drive.hasInput).toBe(true);
});
+ it('鼠标垂直反转默认为关闭,开启后只反转俯仰方向', () => {
+ const normal = new CameraDrive({
+ mode: 'keyboard-mouse',
+ invertMouseY: false,
+ mouseSensitivity: 1,
+ smoothing: 30,
+ });
+ const inverted = new CameraDrive({
+ mode: 'keyboard-mouse',
+ invertMouseY: true,
+ mouseSensitivity: 1,
+ smoothing: 30,
+ });
+ const normalNode = makeCameraNode();
+ const invertedNode = makeCameraNode();
+ normal.attach(normalNode);
+ inverted.attach(invertedNode);
+
+ expect(new CameraDrive().getSettings().invertMouseY).toBe(false);
+ normal.look(40, 20);
+ inverted.look(40, 20);
+ normal.update(1 / 60);
+ inverted.update(1 / 60);
+
+ expect(normalNode.rotation.y).toBeCloseTo(invertedNode.rotation.y, 8);
+ expect(normalNode.rotation.x).toBeCloseTo(-invertedNode.rotation.x, 8);
+ expect(Math.abs(normalNode.rotation.x)).toBeGreaterThan(0.001);
+ });
+
+ it('切换鼠标垂直反转会清除残余视角,但保留已按下的移动键', () => {
+ const drive = new CameraDrive({
+ mode: 'keyboard-mouse',
+ invertMouseY: false,
+ speed: 3,
+ smoothing: 30,
+ });
+ const node = makeCameraNode();
+ drive.attach(node);
+ drive.press('KeyW');
+ drive.update(0.2);
+ drive.look(80, 40);
+ const rotationAtToggle = node.quaternion.clone();
+ const positionAtToggle = node.position.clone();
+
+ drive.setSettings({ invertMouseY: true });
+ drive.update(0.1);
+
+ expect(node.quaternion.angleTo(rotationAtToggle)).toBeLessThan(1e-9);
+ expect(node.position.distanceTo(positionAtToggle)).toBeGreaterThan(0.05);
+ expect(drive.hasInput).toBe(true);
+ });
+
it('纯键盘模式忽略鼠标视角,切换模式会清除残余视角输入', () => {
const drive = new CameraDrive({ mode: 'keyboard-mouse', mouseSensitivity: 1 });
const node = makeCameraNode();
diff --git a/packages/studio/test/timeline-panel.test.tsx b/packages/studio/test/timeline-panel.test.tsx
index 88e76ad..03e09ed 100644
--- a/packages/studio/test/timeline-panel.test.tsx
+++ b/packages/studio/test/timeline-panel.test.tsx
@@ -125,7 +125,7 @@ describe('TimelinePanel:运输控制、标尺、泳道与分镜', () => {
expect(second.session.startRecording).toHaveBeenCalledWith('cam');
});
- it('录制前可选择操控模式并调整速度、短按步长和鼠标灵敏度', () => {
+ it('录制前可选择操控模式并调整速度、短按步长、鼠标灵敏度和垂直反转', () => {
const view = mountPanel({}, ['cam']);
const keyboardMouse = screen.getByRole('button', { name: '键盘移动 + 鼠标视角' });
const keyboardOnly = screen.getByRole('button', { name: '纯键盘操控' });
@@ -138,16 +138,32 @@ describe('TimelinePanel:运输控制、标尺、泳道与分镜', () => {
const speed = screen.getByLabelText('连续移动速度');
const tapStep = screen.getByLabelText('短按移动步长');
const sensitivity = screen.getByLabelText('鼠标视角灵敏度');
+ const invertMouseY = screen.getByRole('checkbox', { name: '鼠标垂直反转' });
expect(speed).toHaveAttribute('type', 'range');
expect(tapStep).toHaveAttribute('type', 'range');
expect(sensitivity).toHaveAttribute('type', 'range');
+ expect(invertMouseY).not.toBeChecked();
fireEvent.change(speed, { target: { value: '4.5' } });
fireEvent.change(tapStep, { target: { value: '0.2' } });
fireEvent.change(sensitivity, { target: { value: '1.4' } });
+ fireEvent.click(invertMouseY);
expect(view.session.setCameraControlSettings).toHaveBeenCalledWith({ speed: 4.5 });
expect(view.session.setCameraControlSettings).toHaveBeenCalledWith({ tapStep: 0.2 });
expect(view.session.setCameraControlSettings).toHaveBeenCalledWith({ mouseSensitivity: 1.4 });
+ expect(view.session.setCameraControlSettings).toHaveBeenCalledWith({ invertMouseY: true });
+ view.unmount();
+
+ const inverted = mountPanel({
+ state: {
+ ...baseState(),
+ cameraControls: { ...DEFAULT_CAMERA_DRIVE_SETTINGS, invertMouseY: true },
+ },
+ }, ['cam']);
+ const checkedInvertMouseY = screen.getByRole('checkbox', { name: '鼠标垂直反转' });
+ expect(checkedInvertMouseY).toBeChecked();
+ fireEvent.click(checkedInvertMouseY);
+ expect(inverted.session.setCameraControlSettings).toHaveBeenCalledWith({ invertMouseY: false });
});
it('录制中:播放键显示 ■,点击停止录制;录制暂停态点击继续', () => {
diff --git a/packages/studio/test/use-timeline-session.test.tsx b/packages/studio/test/use-timeline-session.test.tsx
index 3df9e8a..032483e 100644
--- a/packages/studio/test/use-timeline-session.test.tsx
+++ b/packages/studio/test/use-timeline-session.test.tsx
@@ -51,6 +51,7 @@ describe('useTimelineSession:录制/回放会话(AC1 数据链路 + AC2 失
expect(live().state.snapEnabled).toBe(true);
expect(live().state.loopEnabled).toBe(true);
expect(live().state.cameraControls.mode).toBe('keyboard-mouse');
+ expect(live().state.cameraControls.invertMouseY).toBe(false);
});
it('机位操控参数按会话保存、过滤非有限值并夹取范围,切换项目后保持', () => {
@@ -60,6 +61,7 @@ describe('useTimelineSession:录制/回放会话(AC1 数据链路 + AC2 失
speed: 99,
tapStep: -1,
mouseSensitivity: Number.NaN,
+ invertMouseY: true,
}));
expect(live().state.cameraControls).toMatchObject({
@@ -67,6 +69,7 @@ describe('useTimelineSession:录制/回放会话(AC1 数据链路 + AC2 失
speed: CAMERA_DRIVE_LIMITS.speed.max,
tapStep: CAMERA_DRIVE_LIMITS.tapStep.min,
mouseSensitivity: 1,
+ invertMouseY: true,
});
act(() => editor.openProject({ ...createSampleProject(), uri: 'lumora://camera-controls-next' }));
@@ -75,7 +78,23 @@ describe('useTimelineSession:录制/回放会话(AC1 数据链路 + AC2 失
speed: CAMERA_DRIVE_LIMITS.speed.max,
tapStep: CAMERA_DRIVE_LIMITS.tapStep.min,
mouseSensitivity: 1,
+ invertMouseY: true,
});
+
+ act(() => live().setCameraControlSettings({ invertMouseY: false }));
+ expect(live().state.cameraControls.invertMouseY).toBe(false);
+ });
+
+ it('重新挂载编辑器会话后,鼠标垂直反转随其他机位参数恢复默认值', () => {
+ mount();
+ act(() => live().setCameraControlSettings({ invertMouseY: true }));
+ expect(live().state.cameraControls.invertMouseY).toBe(true);
+
+ unmount?.();
+ unmount = null;
+ mount();
+
+ expect(live().state.cameraControls.invertMouseY).toBe(false);
});
it('togglePlay 切换播放状态', () => {