From f9fc20e6e397b0bc3e100f7203baab42fbbd89d6 Mon Sep 17 00:00:00 2001 From: Seasons Change <1204992313@qq.com> Date: Mon, 7 Sep 2026 18:58:09 +0800 Subject: [PATCH 1/3] fix(studio): TML-563 invert mouse Y and record objects Co-authored-by: multica-agent --- e2e/timeline.spec.ts | 49 +++++++++++ packages/core/src/editor/scene-editor.ts | 2 +- .../studio/src/components/LumoraStudio.tsx | 4 +- .../src/components/editor/EditorViewport.tsx | 12 +-- .../src/components/editor/PlaybackDriver.tsx | 6 +- .../src/components/editor/TimelinePanel.tsx | 48 ++++++----- .../src/components/editor/camera-drive.ts | 36 +++++++- .../components/editor/timeline-recorder.ts | 21 +++-- .../studio/src/hooks/use-timeline-session.ts | 85 ++++++++++--------- packages/studio/test/camera-drive.test.ts | 35 ++++++++ .../studio/test/timeline-recorder.test.ts | 16 ++++ .../studio/test/use-timeline-session.test.tsx | 23 ++++- 12 files changed, 255 insertions(+), 82 deletions(-) diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 8684d24..6c3d35a 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -337,6 +337,55 @@ test('camera takeover guidance and viewport context-menu suppression follow the expect(await rightDrag(page, viewport)).toBe(true); }); +test('mouse vertical inversion is accessible and survives off/on/reload', async ({ page }) => { + const invert = page.getByTestId('camera-control-invert-y'); + await expect(invert).toHaveAccessibleName('鼠标垂直反转'); + await expect(invert).not.toBeChecked(); + + await invert.check(); + await expect(invert).toBeChecked(); + await page.reload(); + await page.getByTestId('open-sample-project').click(); + await expect(page.getByTestId('camera-control-invert-y')).toBeChecked(); + + await page.getByTestId('camera-control-invert-y').uncheck(); + await page.reload(); + await page.getByTestId('open-sample-project').click(); + await expect(page.getByTestId('camera-control-invert-y')).not.toBeChecked(); +}); + +test('non-camera cube recording creates position/rotation tracks and replays them', async ({ page }) => { + await page.getByTestId('tree-row-sample-cube').click(); + const record = page.getByTestId('timeline-record'); + await expect(record).toBeEnabled(); + await record.click(); + await expect(page.getByTestId('overwrite-confirm')).toBeVisible(); + await page.getByText('覆盖录制').click(); + await expect(record).toHaveText('■'); + + const viewport = page.getByTestId('lumora-viewport'); + await viewport.focus(); + const initialPosition = await page.getByTestId('inspector-axis-2').inputValue(); + await page.keyboard.down('w'); + await page.waitForTimeout(350); + await page.keyboard.up('w'); + await page.keyboard.down('ArrowRight'); + await page.waitForTimeout(350); + await page.keyboard.up('ArrowRight'); + + await record.click(); + await expect(record).not.toHaveText('■'); + await expect(page.locator('.lumora-timeline__lane').filter({ hasText: '录制立方体·位置' })).toHaveCount(1); + await expect(page.locator('.lumora-timeline__lane').filter({ hasText: '录制立方体·旋转' })).toHaveCount(1); + + // The selected object is reserved for gizmo edits; move selection away so playback owns the cube. + await page.getByTestId('tree-row-sample-camera').click(); + await page.getByTestId('timeline-play').click(); + await page.waitForTimeout(500); + await expect.poll(() => page.getByTestId('inspector-axis-2').inputValue()).not.toBe(initialPosition); + await page.getByTestId('timeline-play').click(); +}); + test('real right-drag drives only an unblocked POV and suppresses complete gestures', async ({ page }) => { const viewport = page.getByTestId('lumora-viewport'); const status = page.getByTestId('camera-control-status'); diff --git a/packages/core/src/editor/scene-editor.ts b/packages/core/src/editor/scene-editor.ts index e0a08fb..e6fd236 100644 --- a/packages/core/src/editor/scene-editor.ts +++ b/packages/core/src/editor/scene-editor.ts @@ -841,7 +841,7 @@ export class SceneEditor { const nextTracks = project.tracks.map((t) => { const incoming = byKey.get(`${t.objectId}\u0000${t.targetPath}`); // 录制覆盖:整体替换关键帧并重新启用(禁用轨道录制后必须可回放) - return incoming ? { ...t, keyframes: incoming.keyframes, disabled: false } : t; + return incoming ? { ...t, name: incoming.name, keyframes: incoming.keyframes, disabled: false } : t; }); const existingKeys = new Set(project.tracks.map((t) => `${t.objectId}\u0000${t.targetPath}`)); for (const track of owned) { diff --git a/packages/studio/src/components/LumoraStudio.tsx b/packages/studio/src/components/LumoraStudio.tsx index 729b42c..196ed4b 100644 --- a/packages/studio/src/components/LumoraStudio.tsx +++ b/packages/studio/src/components/LumoraStudio.tsx @@ -519,8 +519,8 @@ export const LumoraStudio = forwardRef(fu const project = editor.getProject(); const selection = editor.getSelection(); const selected = project && selection.length === 1 ? findObject(project, selection[0]!) : null; - if (selected?.type === 'camera') activeSession.startRecording(selected.id); - else showToast('请先选择一个机位再开始录制', 'error'); + if (selected) activeSession.startRecording(selected.id); + else showToast('请先选择一个可动画对象再开始录制', 'error'); return; } if ((event.ctrlKey || event.metaKey) && key === 'z') { diff --git a/packages/studio/src/components/editor/EditorViewport.tsx b/packages/studio/src/components/editor/EditorViewport.tsx index 5919dba..ca09aca 100644 --- a/packages/studio/src/components/editor/EditorViewport.tsx +++ b/packages/studio/src/components/editor/EditorViewport.tsx @@ -231,15 +231,15 @@ export function EditorViewport({ // Selection chooses the idle drive target. Once recording starts, the // recorder owns that identity until the session ends, even if selection changes. - const selectedCameraId = useMemo(() => { + const selectedObjectId = useMemo(() => { if (!project || selection.length !== 1) return null; const object = findObject(project, selection[0]!); - return object && object.type === 'camera' ? object.id : null; + return object?.id ?? null; }, [project, selection]); const povCameraId = view.viewMode !== 'director' ? view.viewMode.cameraObjectId : null; const drivenCameraId = session?.state.recording - ? session.recorder.recordingCameraId - : povCameraId ?? selectedCameraId; + ? session.recorder.recordingObjectId + : povCameraId ?? (selectedObjectId && findObject(project!, selectedObjectId)?.type === 'camera' ? selectedObjectId : null); useCameraDrive( session, @@ -258,10 +258,10 @@ export function EditorViewport({ // 录制采样源:视口把机位节点映射为通道样本(录制期间节点由驾驶/静止接管) useEffect(() => { if (!session) return; - session.setCaptureSource((cameraId) => { + session.setCaptureSource((objectId) => { const root = rootRef.current; if (!root) return null; - const node = findNode(root, cameraId); + const node = findNode(root, objectId); if (!node) return null; return captureCameraSample(node); }); diff --git a/packages/studio/src/components/editor/PlaybackDriver.tsx b/packages/studio/src/components/editor/PlaybackDriver.tsx index 47fd3f0..10ee625 100644 --- a/packages/studio/src/components/editor/PlaybackDriver.tsx +++ b/packages/studio/src/components/editor/PlaybackDriver.tsx @@ -123,7 +123,7 @@ export function PlaybackDriver({ continue; } if (skip?.has(objectId)) continue; - if (recorder.active && objectId === recorder.recordingCameraId) continue; + if (recorder.active && objectId === recorder.recordingObjectId) continue; const object = project.objects.find((candidate) => candidate.id === objectId); if (!object) { pendingRestoreTargets.delete(objectId); @@ -136,7 +136,7 @@ export function PlaybackDriver({ } for (const track of project.tracks) { if (track.disabled) continue; - if (recorder.active && track.objectId === recorder.recordingCameraId) continue; + if (recorder.active && track.objectId === recorder.recordingObjectId) continue; if (skip?.has(track.objectId)) continue; const node = findNode(root, track.objectId); if (!node) continue; @@ -160,7 +160,7 @@ export function PlaybackDriver({ const project = editor.getProject(); if (!root || !project) return; for (const object of project.objects) { - if (recorder.active && object.id === recorder.recordingCameraId) continue; + if (recorder.active && object.id === recorder.recordingObjectId) continue; const node = findNode(root, object.id); if (!node) continue; restoreObjectOnNode(node, object); diff --git a/packages/studio/src/components/editor/TimelinePanel.tsx b/packages/studio/src/components/editor/TimelinePanel.tsx index e4ada6b..cd43c87 100644 --- a/packages/studio/src/components/editor/TimelinePanel.tsx +++ b/packages/studio/src/components/editor/TimelinePanel.tsx @@ -97,22 +97,21 @@ export function TimelinePanel({ const zoomRef = useRef(zoom); zoomRef.current = zoom; - const selectedCamera = useMemo(() => { + const selectedObject = useMemo(() => { if (selection.length !== 1) return null; const object = findObject(project, selection[0]!); - return object && object.type === 'camera' ? object : null; + return object ?? null; }, [project, selection]); - const driveCamera = useMemo(() => { - const cameraId = state.recording - ? session.recorder.recordingCameraId + const driveObject = useMemo(() => { + const objectId = state.recording + ? session.recorder.recordingObjectId : view.viewMode === 'director' ? null : view.viewMode.cameraObjectId; - if (!cameraId) return null; - const object = findObject(project, cameraId); - return object?.type === 'camera' ? object : null; - }, [project, session.recorder.recordingCameraId, state.recording, view.viewMode]); + if (!objectId) return null; + return findObject(project, objectId) ?? null; + }, [project, session.recorder.recordingObjectId, state.recording, view.viewMode]); const bodyRef = useRef(null); const trackLaneRefs = useRef(new Map()); const rulerRef = useRef(null); @@ -307,11 +306,11 @@ export function TimelinePanel({ recordingPaused: state.recordingPaused, playing: state.playing, recording: state.recording, - cameraId: driveCamera?.id ?? null, - cameraName: driveCamera?.name ?? null, + cameraId: driveObject?.id ?? null, + cameraName: driveObject?.name ?? null, tracks: project.tracks, }), - [driveCamera, driveEnabled, project.tracks, state.overwritePending, state.playing, state.recording, state.recordingPaused], + [driveObject, driveEnabled, project.tracks, state.overwritePending, state.playing, state.recording, state.recordingPaused], ); const driveBlocked = blockers.length > 0; const locateTakeoverTrack = useCallback(() => { @@ -339,8 +338,8 @@ export function TimelinePanel({ )} )) - : driveCamera - ? `机位“${driveCamera.name}”可手动操控。` + : driveObject + ? `${driveObject.type === 'camera' ? '机位' : '对象'}“${driveObject.name}”可手动操控。` : '导演视图可手动操控。'; const recordClick = () => { @@ -349,7 +348,7 @@ export function TimelinePanel({ else session.stopRecording(); return; } - if (selectedCamera) session.startRecording(selectedCamera.id); + if (selectedObject) session.startRecording(selectedObject.id); }; // 覆盖确认模态:真模态语义 —— 初始聚焦首个可聚焦项(容器不再拿焦点,消除 @@ -386,15 +385,15 @@ export function TimelinePanel({ className={`lumora-timeline__record${state.recording ? ' lumora-timeline__record--on' : ''}`} data-testid="timeline-record" title={ - !selectedCamera && !state.recording - ? `选中一个机位后开始录制(${formatShortcut(recordingShortcut)})` + !selectedObject && !state.recording + ? `选中一个可动画对象后开始录制(${formatShortcut(recordingShortcut)})` : state.recordingPaused ? `继续录制(${formatShortcut(recordingShortcut)})` : state.recording ? `停止录制(${formatShortcut(recordingShortcut)})` - : `录制机位运动(${formatShortcut(recordingShortcut)})` + : `录制对象运动(${formatShortcut(recordingShortcut)})` } - disabled={!selectedCamera && !state.recording} + disabled={!selectedObject && !state.recording} onClick={recordClick} > {state.recordingPaused ? '▶' : state.recording ? '■' : '●'} @@ -481,6 +480,17 @@ export function TimelinePanel({ {state.cameraControls.mouseSensitivity.toFixed(1)} + ; + +function getBooleanStorage(storage?: BooleanStorage): BooleanStorage | null { + if (storage) return storage; + try { + return typeof window !== 'undefined' && window.localStorage ? window.localStorage : null; + } catch { + return null; + } +} + +export function loadInvertMouseY(storage?: BooleanStorage): boolean { + const value = getBooleanStorage(storage)?.getItem(INVERT_MOUSE_Y_STORAGE_KEY); + return value === 'true'; +} + +export function saveInvertMouseY(enabled: boolean, storage?: BooleanStorage): boolean { + const target = getBooleanStorage(storage); + if (!target) return false; + try { + target.setItem(INVERT_MOUSE_Y_STORAGE_KEY, String(enabled)); + return true; + } catch { + return false; + } +} + export const CAMERA_DRIVE_LIMITS = Object.freeze({ speed: Object.freeze({ min: 0.1, max: 20 }), tapStep: Object.freeze({ min: 0.01, max: 2 }), @@ -155,6 +187,7 @@ export function normalizeCameraDriveSettings( CAMERA_DRIVE_LIMITS.mouseSensitivity.min, CAMERA_DRIVE_LIMITS.mouseSensitivity.max, ), + invertMouseY: typeof settings.invertMouseY === 'boolean' ? settings.invertMouseY : base.invertMouseY, smoothing: bounded( settings.smoothing, base.smoothing, @@ -613,7 +646,8 @@ export class CameraDrive { if (this.settings.mode === 'keyboard-mouse' && this.lookDelta.lengthSq() > 1e-9) { const yaw = -this.lookDelta.x * k * mouseSensitivity * LOOK_RADIANS_PER_PIXEL; - const pitch = this.lookDelta.y * k * mouseSensitivity * LOOK_RADIANS_PER_PIXEL; + const pitch = + this.lookDelta.y * (this.settings.invertMouseY ? -1 : 1) * k * mouseSensitivity * LOOK_RADIANS_PER_PIXEL; rotateOnWorldAxis(target, UP_VECTOR, yaw); rotateOnLocalAxis(target, pitch); this.lookDelta.multiplyScalar(1 - k); diff --git a/packages/studio/src/components/editor/timeline-recorder.ts b/packages/studio/src/components/editor/timeline-recorder.ts index 0cdcf42..4a68f99 100644 --- a/packages/studio/src/components/editor/timeline-recorder.ts +++ b/packages/studio/src/components/editor/timeline-recorder.ts @@ -8,7 +8,7 @@ import type { TrackSample } from '@lumora/core'; import type { CaptureNodeSample } from './camera-drive'; /** 采样源:给定机位对象 id 返回节点通道值;无节点或不可采时返回 null */ -export type CaptureSource = (cameraObjectId: string) => CaptureNodeSample | null; +export type CaptureSource = (objectId: string) => CaptureNodeSample | null; export interface RecorderChannels { position: TrackSample[]; @@ -18,7 +18,7 @@ export interface RecorderChannels { } export class TimelineRecorder { - private cameraId: string | null = null; + private objectId: string | null = null; /** 录制绑定项目身份:提交前须与当前项目 uri 一致,否则样本作废(TML-52 审查第 7 项) */ private projectUri: string | null = null; private paused = true; @@ -33,11 +33,16 @@ export class TimelineRecorder { } get active(): boolean { - return this.cameraId !== null; + return this.objectId !== null; } + get recordingObjectId(): string | null { + return this.objectId; + } + + /** Backwards-compatible alias for integrations that still call this a camera take. */ get recordingCameraId(): string | null { - return this.cameraId; + return this.objectId; } get boundProjectUri(): string | null { @@ -49,8 +54,8 @@ export class TimelineRecorder { } /** 开始录制:绑定机位(及所属项目)并清空上一轮样本 */ - start(cameraObjectId: string, projectUri: string | null): void { - this.cameraId = cameraObjectId; + start(objectId: string, projectUri: string | null): void { + this.objectId = objectId; this.projectUri = projectUri; this.paused = false; this.positionSamples = []; @@ -72,7 +77,7 @@ export class TimelineRecorder { /** 采样当前时刻节点状态;返回是否采集到样本 */ sample(time: number): boolean { if (!this.active || this.paused || !this.source) return false; - const capture = this.source(this.cameraId!); + const capture = this.source(this.objectId!); if (!capture) return false; this.positionSamples.push({ time, value: capture.position }); this.rotationSamples.push({ time, value: capture.rotation }); @@ -102,7 +107,7 @@ export class TimelineRecorder { stop(): RecorderChannels | null { if (!this.active) return null; const channels = this.snapshot()!; - this.cameraId = null; + this.objectId = null; this.projectUri = null; this.paused = true; this.positionSamples = []; diff --git a/packages/studio/src/hooks/use-timeline-session.ts b/packages/studio/src/hooks/use-timeline-session.ts index fd8d496..21154cc 100644 --- a/packages/studio/src/hooks/use-timeline-session.ts +++ b/packages/studio/src/hooks/use-timeline-session.ts @@ -21,7 +21,9 @@ import { TimelineRecorder } from '../components/editor/timeline-recorder'; import type { CaptureSource } from '../components/editor/timeline-recorder'; import { DEFAULT_CAMERA_DRIVE_SETTINGS, + loadInvertMouseY, normalizeCameraDriveSettings, + saveInvertMouseY, } from '../components/editor/camera-drive'; import type { CameraDriveSettings } from '../components/editor/camera-drive'; import { showToast } from '../components/editor/toasts'; @@ -65,8 +67,8 @@ export interface TimelineSession { setLoop(enabled: boolean): void; setCaptureSource(source: CaptureSource | null): void; setCameraControlSettings(settings: Partial): void; - /** 开始录制指定机位;已有录制轨道时进入覆盖确认 */ - startRecording(cameraObjectId: string): void; + /** 开始录制指定对象;已有录制轨道时进入覆盖确认 */ + startRecording(objectId: string): void; confirmOverwrite(): void; cancelOverwrite(): void; /** 恢复暂停中的录制(同时恢复播放) */ @@ -93,10 +95,10 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { zoom: timeline.getZoom(), snapEnabled: timeline.isSnapEnabled(), loopEnabled: timeline.isLoopEnabled(), - cameraControls: { ...DEFAULT_CAMERA_DRIVE_SETTINGS }, + cameraControls: { ...DEFAULT_CAMERA_DRIVE_SETTINGS, invertMouseY: loadInvertMouseY() }, })); - const pendingRecordingRef = useRef<{ sessionToken: number; cameraId: string } | null>(null); + const pendingRecordingRef = useRef<{ sessionToken: number; objectId: string } | null>(null); /** 本 hook 观察中的项目会话令牌:project:changed 里据此判定「会话切换」 * (打开/重开/重置)。与 recordedSessionTokenRef 分离 —— 后者仅录制时非空, * 不能作为会话变化依据(从未录制时不得把每次编辑误判为切换) */ @@ -111,12 +113,12 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { * 调用返回前切换项目或执行另一录制动作。只有仍持有当前代、会话和机位的 * 外层动作才允许写 React 状态。 */ const recordingOperationGenerationRef = useRef(0); - type RecordingOperation = { generation: number; sessionToken: number; cameraId: string }; + type RecordingOperation = { generation: number; sessionToken: number; objectId: string }; const captureRecordingOperation = useCallback( - (cameraId: string): RecordingOperation => ({ + (objectId: string): RecordingOperation => ({ generation: ++recordingOperationGenerationRef.current, sessionToken: editor.getSessionToken(), - cameraId, + objectId, }), [editor], ); @@ -124,7 +126,7 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { (operation: RecordingOperation): boolean => recordingOperationGenerationRef.current === operation.generation && editor.isCurrentSession(operation.sessionToken) && - recorder.recordingCameraId === operation.cameraId, + recorder.recordingObjectId === operation.objectId, [editor, recorder], ); @@ -202,8 +204,8 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { } if (!recorder.active) { if (!runWhileCurrent(() => timeline.setDuration(getProjectDuration(project)))) return; - } else if (!project.objects.some((o) => o.id === recorder.recordingCameraId)) { - // 录制中绑定机位被删除/撤销:采样源已失效,取消录制并把时长收敛回 + } else if (!project.objects.some((o) => o.id === recorder.recordingObjectId)) { + // 录制中绑定对象被删除/撤销:采样源已失效,取消录制并把时长收敛回 // 项目时长(录制扩容出的时长不得残留,复审阻断 3) cancelRecording(); if (!isCurrentPayload()) return; @@ -259,7 +261,7 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { const pauseOnHidden = () => { const r = recorderRef.current!; if (r.active && !r.isPaused) { - const operation = captureRecordingOperation(r.recordingCameraId!); + const operation = captureRecordingOperation(r.recordingObjectId!); r.pause(); timelineRef.current!.pause(); if (isRecordingOperationCurrent(operation)) { @@ -281,16 +283,16 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { if (recorder.active) { // 录制期间播放键 = 暂停/恢复录制(采样随播放头一起停) if (recorder.isPaused) { - const cameraId = recorder.recordingCameraId!; - const operation = captureRecordingOperation(cameraId); + const recordingObjectId = recorder.recordingObjectId!; + const operation = captureRecordingOperation(recordingObjectId); recorder.resume(); timeline.play(); if (isRecordingOperationCurrent(operation)) { setState((s) => ({ ...s, recording: true, recordingPaused: false, playing: true })); } } else { - const cameraId = recorder.recordingCameraId!; - const operation = captureRecordingOperation(cameraId); + const recordingObjectId = recorder.recordingObjectId!; + const operation = captureRecordingOperation(recordingObjectId); recorder.pause(); timeline.pause(); if (isRecordingOperationCurrent(operation)) { @@ -304,8 +306,8 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { const pause = useCallback(() => { if (recorder.active) { - const cameraId = recorder.recordingCameraId!; - const operation = captureRecordingOperation(cameraId); + const recordingObjectId = recorder.recordingObjectId!; + const operation = captureRecordingOperation(recordingObjectId); recorder.pause(); timeline.pause(); if (isRecordingOperationCurrent(operation)) { @@ -339,19 +341,20 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { ...current, cameraControls: normalizeCameraDriveSettings(settings, current.cameraControls), })); + if (typeof settings.invertMouseY === 'boolean') saveInvertMouseY(settings.invertMouseY); }, []); const beginRecording = useCallback( - (cameraObjectId: string) => { + (objectId: string) => { const project = editor.getProject(); const projectUri = project?.uri ?? null; const sessionToken = editor.getSessionToken(); // 绑定身份后再采样:项目切换/重开/相机删除时按身份取消(审查第 7 项; // 复审阻断 3:会话令牌参与绑定,同 URI 重开不视为同一会话) - recorder.start(cameraObjectId, projectUri); + recorder.start(objectId, projectUri); recordedSessionTokenRef.current = sessionToken; recordedProjectUriRef.current = projectUri; - const operation = captureRecordingOperation(cameraObjectId); + const operation = captureRecordingOperation(objectId); timeline.play(); if (isRecordingOperationCurrent(operation)) { setState((s) => ({ ...s, recording: true, recordingPaused: false })); @@ -371,20 +374,20 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { if (!pending) return; if (!editor.isCurrentSession(pending.sessionToken)) return; const project = editor.getProject(); - const camera = project?.objects.find((o) => o.id === pending.cameraId); - if (!camera || camera.type !== 'camera') { - showToast('目标机位已不存在,无法录制', 'error'); + const object = project?.objects.find((o) => o.id === pending.objectId); + if (!object) { + showToast('目标对象已不存在,无法录制', 'error'); return; } - beginRecording(pending.cameraId); + beginRecording(pending.objectId); }, [beginRecording, editor]); const startRecording = useCallback( - (cameraObjectId: string) => { + (objectId: string) => { if (recorder.active) { if (recorder.isPaused) { - const cameraId = recorder.recordingCameraId!; - const operation = captureRecordingOperation(cameraId); + const recordingObjectId = recorder.recordingObjectId!; + const operation = captureRecordingOperation(recordingObjectId); recorder.resume(); timeline.play(); if (isRecordingOperationCurrent(operation)) { @@ -395,20 +398,20 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { } const project = editor.getProject(); if (!project) return; - const camera = project.objects.find((o) => o.id === cameraObjectId); - if (!camera || camera.type !== 'camera') return; + const object = project.objects.find((o) => o.id === objectId); + if (!object) return; const hasRecorded = project.tracks.some( - (t) => t.objectId === cameraObjectId && t.keyframes.length > 0, + (t) => t.objectId === objectId && t.keyframes.length > 0, ); if (hasRecorded) { pendingRecordingRef.current = { sessionToken: editor.getSessionToken(), - cameraId: cameraObjectId, + objectId, }; setState((s) => ({ ...s, overwritePending: true })); return; } - beginRecording(cameraObjectId); + beginRecording(objectId); }, [captureRecordingOperation, editor, isRecordingOperationCurrent, recorder, timeline, beginRecording], ); @@ -420,8 +423,8 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { const resumeRecording = useCallback(() => { if (recorder.active && recorder.isPaused) { - const cameraId = recorder.recordingCameraId!; - const operation = captureRecordingOperation(cameraId); + const recordingObjectId = recorder.recordingObjectId!; + const operation = captureRecordingOperation(recordingObjectId); recorder.resume(); timeline.play(); if (isRecordingOperationCurrent(operation)) { @@ -447,17 +450,17 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { setState((s) => ({ ...s, recording: false, recordingPaused: false })); return { ok: true }; } - const cameraObjectId = recorder.recordingCameraId; + const recordingObjectId = recorder.recordingObjectId; const projectUri = recorder.boundProjectUri; const channels = recorder.snapshot(); // 停止时重验绑定身份:项目已切换或相机已删除 → 丢弃样本,不提交 const project = editor.getProject(); - const camera = - channels && project && project.uri === projectUri && cameraObjectId - ? (project.objects.find((o) => o.id === cameraObjectId) ?? null) + const object = + channels && project && project.uri === projectUri && recordingObjectId + ? (project.objects.find((o) => o.id === recordingObjectId) ?? null) : null; - if (camera && channels && cameraObjectId) { - const label = camera.name; + if (object && channels && recordingObjectId) { + const label = object.name; const tracks: TrackData[] = []; for (const channel of ['position', 'rotation', 'focalLength'] as const) { const samples = channels[channel]; @@ -467,7 +470,7 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { const keyframes = simplifySamples(samples, { channel }) as TrackKeyframeData[]; if (keyframes.length === 0) continue; tracks.push( - createTrack(cameraObjectId, channel as TrackTargetPath, `录制${label}·${CHANNEL_LABELS[channel]}`, keyframes), + createTrack(recordingObjectId, channel as TrackTargetPath, `录制${label}·${CHANNEL_LABELS[channel]}`, keyframes), ); } if (tracks.length > 0) { diff --git a/packages/studio/test/camera-drive.test.ts b/packages/studio/test/camera-drive.test.ts index fab6c0b..b512583 100644 --- a/packages/studio/test/camera-drive.test.ts +++ b/packages/studio/test/camera-drive.test.ts @@ -5,13 +5,17 @@ import type { SceneObjectData } from '@lumora/core'; import { CAMERA_DRIVE_LIMITS, CameraDrive, + DEFAULT_CAMERA_DRIVE_SETTINGS, + INVERT_MOUSE_Y_STORAGE_KEY, MAX_FOCAL_MM, MIN_FOCAL_MM, applyCameraWorldDelta, captureCameraSample, hasSingularWorldTransform, + loadInvertMouseY, getWorldOrthonormalQuaternion, getWorldRigidQuaternion, + saveInvertMouseY, restoreObjectOnNode, syncRigidCameraProxy, } from '../src/components/editor/camera-drive'; @@ -38,6 +42,37 @@ function makeCompensatedNearSingularCamera(): THREE.PerspectiveCamera { } describe('CameraDrive:键鼠驾驶积分器', () => { + it('鼠标垂直反转只反转 look 的 Y 增量,水平增量保持一致', () => { + const normalNode = makeCameraNode(); + const invertedNode = makeCameraNode(); + const normal = new CameraDrive({ ...DEFAULT_CAMERA_DRIVE_SETTINGS, smoothing: 30, invertMouseY: false }); + const inverted = new CameraDrive({ ...DEFAULT_CAMERA_DRIVE_SETTINGS, smoothing: 30, invertMouseY: true }); + normal.attach(normalNode); + inverted.attach(invertedNode); + normal.look(12, 8); + inverted.look(12, 8); + normal.update(0.1); + inverted.update(0.1); + + expect(invertedNode.rotation.y).toBeCloseTo(normalNode.rotation.y, 8); + expect(invertedNode.rotation.x).toBeCloseTo(-normalNode.rotation.x, 8); + }); + + it('鼠标垂直反转设置可保存、读取,并区分关闭与开启', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + + expect(INVERT_MOUSE_Y_STORAGE_KEY).toContain('invert'); + expect(loadInvertMouseY(storage)).toBe(false); + expect(saveInvertMouseY(true, storage)).toBe(true); + expect(loadInvertMouseY(storage)).toBe(true); + expect(saveInvertMouseY(false, storage)).toBe(true); + expect(loadInvertMouseY(storage)).toBe(false); + }); + it('applies a world-space camera delta through a transformed parent', () => { const parent = new THREE.Group(); parent.position.set(3, -2, 4); diff --git a/packages/studio/test/timeline-recorder.test.ts b/packages/studio/test/timeline-recorder.test.ts index f76e390..7a5aed6 100644 --- a/packages/studio/test/timeline-recorder.test.ts +++ b/packages/studio/test/timeline-recorder.test.ts @@ -30,6 +30,22 @@ describe('TimelineRecorder:采样采集器', () => { expect(channels.focalLength).toHaveLength(2); }); + it('可绑定非摄像机对象并只采集位置/旋转通道', () => { + const recorder = new TimelineRecorder(); + recorder.setCaptureSource( + fixedSource({ position: [4, 5, 6], rotation: [0.1, 0.2, 0.3], focalLength: null }), + ); + recorder.start('cube', 'lumora://test'); + + expect(recorder.recordingObjectId).toBe('cube'); + expect(recorder.recordingCameraId).toBe('cube'); + expect(recorder.sample(0)).toBe(true); + const channels = recorder.stop()!; + expect(channels.position).toHaveLength(1); + expect(channels.rotation).toHaveLength(1); + expect(channels.focalLength).toBeNull(); + }); + it('未 start 时 sample/stop 均为 no-op', () => { const recorder = new TimelineRecorder(); recorder.setCaptureSource(fixedSource(FIXED)); diff --git a/packages/studio/test/use-timeline-session.test.tsx b/packages/studio/test/use-timeline-session.test.tsx index 3df9e8a..04f85ea 100644 --- a/packages/studio/test/use-timeline-session.test.tsx +++ b/packages/studio/test/use-timeline-session.test.tsx @@ -3,7 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SceneEditor, createGroupObject, createSampleProject, getProjectDuration } from '@lumora/core'; import type { RenderHookResult } from '@testing-library/react'; import type { CaptureNodeSample } from '../src/components/editor/camera-drive'; -import { CAMERA_DRIVE_LIMITS } from '../src/components/editor/camera-drive'; +import { + CAMERA_DRIVE_LIMITS, + INVERT_MOUSE_Y_STORAGE_KEY, +} from '../src/components/editor/camera-drive'; import { useTimelineSession } from '../src/hooks/use-timeline-session'; import type { TimelineSession } from '../src/hooks/use-timeline-session'; @@ -78,6 +81,24 @@ describe('useTimelineSession:录制/回放会话(AC1 数据链路 + AC2 失 }); }); + it('鼠标垂直反转按 off/on/reload 持久化', () => { + localStorage.removeItem(INVERT_MOUSE_Y_STORAGE_KEY); + mount(); + expect(live().state.cameraControls.invertMouseY).toBe(false); + + act(() => live().setCameraControlSettings({ invertMouseY: true })); + expect(live().state.cameraControls.invertMouseY).toBe(true); + expect(localStorage.getItem(INVERT_MOUSE_Y_STORAGE_KEY)).toBe('true'); + + unmount?.(); + unmount = null; + mount(); + expect(live().state.cameraControls.invertMouseY).toBe(true); + + act(() => live().setCameraControlSettings({ invertMouseY: false })); + expect(localStorage.getItem(INVERT_MOUSE_Y_STORAGE_KEY)).toBe('false'); + }); + it('togglePlay 切换播放状态', () => { mount(); act(() => live().togglePlay()); From cacfffc5233eada533611fe72dedba00509cd673 Mon Sep 17 00:00:00 2001 From: Seasons Change <1204992313@qq.com> Date: Tue, 8 Sep 2026 10:18:04 +0800 Subject: [PATCH 2/3] fix(studio): close TML-563 recording edge cases Co-authored-by: multica-agent --- e2e/timeline.spec.ts | 49 ++++++++++++++++++- .../src/components/editor/EditorViewport.tsx | 14 +++++- .../src/components/editor/camera-drive.ts | 18 ++++--- .../studio/src/hooks/use-timeline-session.ts | 6 ++- packages/studio/test/camera-drive.test.ts | 24 +++++++++ packages/studio/test/lumora-studio.test.tsx | 4 +- .../studio/test/use-timeline-session.test.tsx | 24 +++++---- 7 files changed, 115 insertions(+), 24 deletions(-) diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 6c3d35a..746530e 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -145,6 +145,18 @@ async function stableCameraPose( throw new Error(`机位 ${cameraId} 未在稳定帧窗口内静止`); } +async function objectPose( + page: Page, + objectId: string, +): Promise<{ position: [number, number, number]; rotation: [number, number, number] }> { + await waitForStableFrame(page); + const text = await page.getByTestId('camera-pose-readout').textContent(); + if (!text) throw new Error('scene pose readout unavailable'); + const pose = JSON.parse(text)[objectId]; + if (!pose) throw new Error(`scene object ${objectId} is not in the pose readout`); + return pose; +} + async function rightDrag( page: Page, viewport: ReturnType, @@ -354,8 +366,37 @@ test('mouse vertical inversion is accessible and survives off/on/reload', async await expect(page.getByTestId('camera-control-invert-y')).not.toBeChecked(); }); +test('changing invertMouseY while holding right button ends the active look gesture', async ({ page }) => { + const viewport = page.getByTestId('lumora-viewport'); + const bounds = await viewport.boundingBox(); + if (!bounds) throw new Error('viewport is unavailable'); + await startRecording(page); + await viewport.focus(); + const before = await cameraPose(page); + await viewport.click({ position: { x: bounds.width * 0.45, y: bounds.height * 0.5 } }); + await page.mouse.move(bounds.x + bounds.width * 0.45, bounds.y + bounds.height * 0.5); + await page.mouse.down({ button: 'right' }); + await page.mouse.move(bounds.x + bounds.width * 0.45, bounds.y + bounds.height * 0.35, { steps: 4 }); + await page.waitForTimeout(80); + const during = await cameraPose(page); + const invert = page.getByTestId('camera-control-invert-y'); + await invert.evaluate((element) => (element as HTMLInputElement).click()); + const afterToggle = await cameraPose(page); + await page.mouse.move(bounds.x + bounds.width * 0.45, bounds.y + bounds.height * 0.65, { steps: 4 }); + await page.waitForTimeout(120); + const afterMove = await cameraPose(page); + await page.mouse.up({ button: 'right' }); + + expect(quaternionAngle(during.rotation, before.rotation)).toBeGreaterThan(0.0001); + expect(quaternionAngle(afterMove.rotation, afterToggle.rotation)).toBeLessThan(0.0001); + await page.getByTestId('timeline-record').click(); +}); + test('non-camera cube recording creates position/rotation tracks and replays them', async ({ page }) => { await page.getByTestId('tree-row-sample-cube').click(); + await expect(page.getByTestId('tree-row-sample-cube')).toHaveAttribute('aria-selected', 'true'); + await page.getByRole('button', { name: '纯键盘操控' }).click(); + await expect(page.getByTestId('tree-row-sample-cube')).toHaveAttribute('aria-selected', 'true'); const record = page.getByTestId('timeline-record'); await expect(record).toBeEnabled(); await record.click(); @@ -365,7 +406,7 @@ test('non-camera cube recording creates position/rotation tracks and replays the const viewport = page.getByTestId('lumora-viewport'); await viewport.focus(); - const initialPosition = await page.getByTestId('inspector-axis-2').inputValue(); + const initialPose = await objectPose(page, 'sample-cube'); await page.keyboard.down('w'); await page.waitForTimeout(350); await page.keyboard.up('w'); @@ -382,7 +423,11 @@ test('non-camera cube recording creates position/rotation tracks and replays the await page.getByTestId('tree-row-sample-camera').click(); await page.getByTestId('timeline-play').click(); await page.waitForTimeout(500); - await expect.poll(() => page.getByTestId('inspector-axis-2').inputValue()).not.toBe(initialPosition); + await expect.poll(async () => { + const pose = await objectPose(page, 'sample-cube'); + return vectorDistance(pose.position, initialPose.position) > 0.001 || + vectorDistance(pose.rotation, initialPose.rotation) > 0.001; + }).toBe(true); await page.getByTestId('timeline-play').click(); }); diff --git a/packages/studio/src/components/editor/EditorViewport.tsx b/packages/studio/src/components/editor/EditorViewport.tsx index ca09aca..53c76ce 100644 --- a/packages/studio/src/components/editor/EditorViewport.tsx +++ b/packages/studio/src/components/editor/EditorViewport.tsx @@ -805,8 +805,12 @@ function useCameraDrive( const st = sessionRef.current?.state; if (st) { const previousMode = drive.getSettings().mode; + const previousInvertMouseY = drive.getSettings().invertMouseY; drive.setSettings(st.cameraControls); - if (drive.getSettings().mode !== previousMode) { + if ( + drive.getSettings().mode !== previousMode || + drive.getSettings().invertMouseY !== previousInvertMouseY + ) { heldKeys.clear(); endLookGesture(); } @@ -1175,9 +1179,15 @@ function CameraPoseReadout({ if (!root || !project) return; const poses: Record = {}; for (const object of project.objects) { - if (object.type !== 'camera') continue; const node = findNode(root, object.id); if (!node) continue; + if (object.type !== 'camera') { + poses[object.id] = { + position: [node.position.x, node.position.y, node.position.z], + rotation: [node.rotation.x, node.rotation.y, node.rotation.z], + }; + continue; + } const focal = (node.userData as Record).focalLength; poses[object.id] = { position: [node.position.x, node.position.y, node.position.z], diff --git a/packages/studio/src/components/editor/camera-drive.ts b/packages/studio/src/components/editor/camera-drive.ts index 2cd2f92..335de25 100644 --- a/packages/studio/src/components/editor/camera-drive.ts +++ b/packages/studio/src/components/editor/camera-drive.ts @@ -485,8 +485,11 @@ export class CameraDrive { setSettings(settings: Partial): 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.settings.invertMouseY !== previousInvertMouseY) { + this.clearMotion(); + } } acceptsKey(code: string): boolean { @@ -663,11 +666,10 @@ export class CameraDrive { } private applyFocal(node: THREE.Object3D, focalLength: number): void { + if (!(node instanceof THREE.PerspectiveCamera)) return; (node.userData as Record).focalLength = focalLength; - if (node instanceof THREE.PerspectiveCamera) { - node.fov = focalLengthToFovDeg(focalLength); - node.updateProjectionMatrix(); - } + node.fov = focalLengthToFovDeg(focalLength); + node.updateProjectionMatrix(); } } @@ -683,9 +685,9 @@ export interface CaptureNodeSample { /** 把录制机位节点映射为采样通道值(focalLength 优先读 userData,否则由 fov 反推) */ export function captureCameraSample(node: THREE.Object3D): CaptureNodeSample { - const focal = - (node.userData as Record).focalLength ?? - (node instanceof THREE.PerspectiveCamera ? fovDegToFocalLength(node.fov) : null); + const focal = node instanceof THREE.PerspectiveCamera + ? (node.userData as Record).focalLength ?? fovDegToFocalLength(node.fov) + : null; return { position: [node.position.x, node.position.y, node.position.z], rotation: [node.rotation.x, node.rotation.y, node.rotation.z], diff --git a/packages/studio/src/hooks/use-timeline-session.ts b/packages/studio/src/hooks/use-timeline-session.ts index 21154cc..97fdc3d 100644 --- a/packages/studio/src/hooks/use-timeline-session.ts +++ b/packages/studio/src/hooks/use-timeline-session.ts @@ -463,6 +463,7 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { const label = object.name; const tracks: TrackData[] = []; for (const channel of ['position', 'rotation', 'focalLength'] as const) { + if (channel === 'focalLength' && object.type !== 'camera') continue; const samples = channels[channel]; if (!samples || samples.length === 0) continue; // 通道语义传给抽稀:rotation 按 slerp 角距离判定(与回放求值同源, @@ -478,7 +479,10 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { const result = editor.commitRecordingTracks(tracks, '录制关键帧'); if (!result.ok) { showToast(result.error.message, 'error'); - setState((s) => ({ ...s, recording: true, recordingPaused: true, playing: false })); + recorder.stop(); + recordedSessionTokenRef.current = null; + recordedProjectUriRef.current = null; + setState((s) => ({ ...s, recording: false, recordingPaused: false, playing: false })); return { ok: false, message: result.error.message }; } } diff --git a/packages/studio/test/camera-drive.test.ts b/packages/studio/test/camera-drive.test.ts index b512583..abd5f3d 100644 --- a/packages/studio/test/camera-drive.test.ts +++ b/packages/studio/test/camera-drive.test.ts @@ -754,6 +754,19 @@ describe('CameraDrive:键鼠驾驶积分器', () => { expect(drive.acceptsKey('ArrowLeft')).toBe(true); }); + it('clears queued look when invertMouseY changes', () => { + const drive = new CameraDrive({ mode: 'keyboard-mouse', mouseSensitivity: 1, smoothing: 8, invertMouseY: false }); + const node = makeCameraNode(); + drive.attach(node); + drive.look(0, 80); + drive.setSettings({ invertMouseY: true }); + const before = node.quaternion.clone(); + drive.update(0.1); + + expect(node.quaternion.angleTo(before)).toBeLessThan(1e-9); + expect(drive.hasInput).toBe(false); + }); + it('鼠标视角限幅且残余输入逐帧衰减,不会产生单帧无界跳变', () => { const huge = new CameraDrive({ mode: 'keyboard-mouse', mouseSensitivity: 1, smoothing: 8 }); const bounded = new CameraDrive({ mode: 'keyboard-mouse', mouseSensitivity: 1, smoothing: 8 }); @@ -815,9 +828,20 @@ describe('captureCameraSample / restoreObjectOnNode', () => { const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100); expect(captureCameraSample(camera).focalLength).toBeGreaterThan(0); const group = new THREE.Group(); + group.userData.focalLength = 24; expect(captureCameraSample(group).focalLength).toBeNull(); }); + it('does not write focalLength when a focal shortcut targets a non-camera node', () => { + const drive = new CameraDrive({ mode: 'keyboard-mouse', smoothing: 8 }); + const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)); + drive.attach(mesh); + drive.press('BracketRight'); + drive.update(0.2); + + expect(mesh.userData.focalLength).toBeUndefined(); + }); + it('restoreObjectOnNode 还原静态位姿与相机参数,清除驾驶焦距标记', () => { const object: SceneObjectData = { id: 'cam', diff --git a/packages/studio/test/lumora-studio.test.tsx b/packages/studio/test/lumora-studio.test.tsx index 88e3d1d..7e620b7 100644 --- a/packages/studio/test/lumora-studio.test.tsx +++ b/packages/studio/test/lumora-studio.test.tsx @@ -520,11 +520,11 @@ describe('LumoraStudio', () => { await expect(handle.current!.close()).resolves.toEqual({ ok: false, message: '模拟录制提交失败' }); expect(dispose).not.toHaveBeenCalled(); - expect(stop).not.toHaveBeenCalled(); + expect(stop).toHaveBeenCalledTimes(1); commit.mockReturnValue({ ok: true }); await expect(handle.current!.close()).resolves.toEqual(expect.objectContaining({ ok: true })); - expect(commit).toHaveBeenCalledTimes(2); + expect(commit).toHaveBeenCalledTimes(1); expect(stop).toHaveBeenCalledTimes(1); expect(dispose).toHaveBeenCalledTimes(1); }); diff --git a/packages/studio/test/use-timeline-session.test.tsx b/packages/studio/test/use-timeline-session.test.tsx index 04f85ea..7a1bf5f 100644 --- a/packages/studio/test/use-timeline-session.test.tsx +++ b/packages/studio/test/use-timeline-session.test.tsx @@ -175,19 +175,25 @@ describe('useTimelineSession:录制/回放会话(AC1 数据链路 + AC2 失 failed = live().stopRecording(); }); expect(failed!).toEqual({ ok: false, message: '模拟录制提交失败' }); - expect(live().recorder.active).toBe(true); - expect(live().recorder.isPaused).toBe(true); - expect(live().state.recording).toBe(true); - expect(live().state.recordingPaused).toBe(true); + expect(live().recorder.active).toBe(false); + expect(live().state.recording).toBe(false); + expect(live().state.recordingPaused).toBe(false); commit.mockRestore(); - let retried: ReturnType; - act(() => { - retried = live().stopRecording(); - }); - expect(retried!).toEqual({ ok: true }); + expect(live().stopRecording()).toEqual({ ok: true }); + }); + + it('non-camera recording filters focal samples and commits position/rotation', () => { + mount(); + act(() => live().setCaptureSource(() => ({ position: [1, 0, 0], rotation: [0, 0.25, 0], focalLength: 35 }))); + act(() => live().startRecording('sample-cube')); + act(() => vi.advanceTimersByTime(300)); + + expect(live().stopRecording()).toEqual({ ok: true }); expect(live().recorder.active).toBe(false); expect(live().state.recording).toBe(false); + expect(editor.getProject()!.tracks.filter((track) => track.objectId === 'sample-cube').map((track) => track.targetPath).sort()) + .toEqual(['position', 'rotation']); }); it('B1 回归:约 5s 持续录制中会话对象身份稳定 —— 驾驶输入不再被会话重建清空', () => { From 226ac61d399aa461c03d547acbf1022330ea6707 Mon Sep 17 00:00:00 2001 From: Seasons Change <1204992313@qq.com> Date: Tue, 8 Sep 2026 11:51:51 +0800 Subject: [PATCH 3/3] fix(studio): retain failed recording sessions Co-authored-by: multica-agent --- .../src/components/editor/camera-drive.ts | 9 +++++-- .../studio/src/hooks/use-timeline-session.ts | 7 +++--- packages/studio/test/camera-drive.test.ts | 11 ++++++++ packages/studio/test/lumora-studio.test.tsx | 11 ++++---- .../studio/test/use-timeline-session.test.tsx | 25 +++++++++++++++---- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/packages/studio/src/components/editor/camera-drive.ts b/packages/studio/src/components/editor/camera-drive.ts index 335de25..850c0ce 100644 --- a/packages/studio/src/components/editor/camera-drive.ts +++ b/packages/studio/src/components/editor/camera-drive.ts @@ -55,8 +55,13 @@ function getBooleanStorage(storage?: BooleanStorage): BooleanStorage | null { } export function loadInvertMouseY(storage?: BooleanStorage): boolean { - const value = getBooleanStorage(storage)?.getItem(INVERT_MOUSE_Y_STORAGE_KEY); - return value === 'true'; + const target = getBooleanStorage(storage); + if (!target) return false; + try { + return target.getItem(INVERT_MOUSE_Y_STORAGE_KEY) === 'true'; + } catch { + return false; + } } export function saveInvertMouseY(enabled: boolean, storage?: BooleanStorage): boolean { diff --git a/packages/studio/src/hooks/use-timeline-session.ts b/packages/studio/src/hooks/use-timeline-session.ts index 97fdc3d..75649c0 100644 --- a/packages/studio/src/hooks/use-timeline-session.ts +++ b/packages/studio/src/hooks/use-timeline-session.ts @@ -479,10 +479,9 @@ export function useTimelineSession(editor: SceneEditor): TimelineSession { const result = editor.commitRecordingTracks(tracks, '录制关键帧'); if (!result.ok) { showToast(result.error.message, 'error'); - recorder.stop(); - recordedSessionTokenRef.current = null; - recordedProjectUriRef.current = null; - setState((s) => ({ ...s, recording: false, recordingPaused: false, playing: false })); + // Keep the paused recorder/session alive so the host can retry the + // atomic commit on the next close() without losing its samples. + setState((s) => ({ ...s, recording: true, recordingPaused: true, playing: false })); return { ok: false, message: result.error.message }; } } diff --git a/packages/studio/test/camera-drive.test.ts b/packages/studio/test/camera-drive.test.ts index abd5f3d..4a2ff82 100644 --- a/packages/studio/test/camera-drive.test.ts +++ b/packages/studio/test/camera-drive.test.ts @@ -73,6 +73,17 @@ describe('CameraDrive:键鼠驾驶积分器', () => { expect(loadInvertMouseY(storage)).toBe(false); }); + it('falls back to disabled when restricted storage throws on read', () => { + const storage = { + getItem: () => { + throw new Error('storage blocked'); + }, + setItem: () => undefined, + }; + + expect(loadInvertMouseY(storage)).toBe(false); + }); + it('applies a world-space camera delta through a transformed parent', () => { const parent = new THREE.Group(); parent.position.set(3, -2, 4); diff --git a/packages/studio/test/lumora-studio.test.tsx b/packages/studio/test/lumora-studio.test.tsx index 7e620b7..22f183a 100644 --- a/packages/studio/test/lumora-studio.test.tsx +++ b/packages/studio/test/lumora-studio.test.tsx @@ -511,22 +511,23 @@ describe('LumoraStudio', () => { focalLength: null, }); const stop = vi.spyOn(TimelineRecorder.prototype, 'stop'); - const commit = vi.spyOn(handle.current!.runtime.editor, 'commitRecordingTracks').mockReturnValue({ + const commit = vi.spyOn(handle.current!.runtime.editor, 'commitRecordingTracks').mockImplementationOnce(() => ({ ok: false, error: new Error('模拟录制提交失败'), - }); + })); const dispose = vi.spyOn(handle.current!.runtime, 'dispose'); screen.getByTestId('timeline-record').click(); await expect(handle.current!.close()).resolves.toEqual({ ok: false, message: '模拟录制提交失败' }); expect(dispose).not.toHaveBeenCalled(); - expect(stop).toHaveBeenCalledTimes(1); + expect(stop).not.toHaveBeenCalled(); - commit.mockReturnValue({ ok: true }); await expect(handle.current!.close()).resolves.toEqual(expect.objectContaining({ ok: true })); - expect(commit).toHaveBeenCalledTimes(1); + expect(commit).toHaveBeenCalledTimes(2); expect(stop).toHaveBeenCalledTimes(1); expect(dispose).toHaveBeenCalledTimes(1); + expect((commit.mock.calls[1]?.[0] ?? []).filter((track) => track.objectId === 'sample-camera').map((track) => track.targetPath).sort()) + .toEqual(['position', 'rotation']); }); it('录制提交同步事件重入 close() 时复用已发布的 single-flight,不重复最终化', async () => { diff --git a/packages/studio/test/use-timeline-session.test.tsx b/packages/studio/test/use-timeline-session.test.tsx index 7a1bf5f..7b44285 100644 --- a/packages/studio/test/use-timeline-session.test.tsx +++ b/packages/studio/test/use-timeline-session.test.tsx @@ -165,22 +165,37 @@ describe('useTimelineSession:录制/回放会话(AC1 数据链路 + AC2 失 act(() => live().startRecording('sample-camera')); act(() => live().confirmOverwrite()); act(() => vi.advanceTimersByTime(300)); - const commit = vi.spyOn(editor, 'commitRecordingTracks').mockReturnValue({ + const commit = vi.spyOn(editor, 'commitRecordingTracks').mockImplementationOnce(() => ({ ok: false, error: new Error('模拟录制提交失败'), - }); + })); let failed: ReturnType; act(() => { failed = live().stopRecording(); }); expect(failed!).toEqual({ ok: false, message: '模拟录制提交失败' }); + expect(commit).toHaveBeenCalledTimes(1); + expect(live().recorder.active).toBe(true); + expect(live().recorder.isPaused).toBe(true); + expect(live().state.recording).toBe(true); + expect(live().state.recordingPaused).toBe(true); + expect(live().state.playing).toBe(false); + expect(live().timeline.getTime()).toBeGreaterThan(0); + + let retry!: ReturnType; + act(() => { + retry = live().stopRecording(); + }); + expect(retry).toEqual({ ok: true }); + expect(commit).toHaveBeenCalledTimes(2); expect(live().recorder.active).toBe(false); expect(live().state.recording).toBe(false); expect(live().state.recordingPaused).toBe(false); - - commit.mockRestore(); - expect(live().stopRecording()).toEqual({ ok: true }); + expect(live().timeline.getTime()).toBe(0); + expect(live().state.duration).toBeCloseTo(getProjectDuration(editor.getProject()!), 6); + expect(editor.getProject()!.tracks.filter((track) => track.objectId === 'sample-camera').map((track) => track.targetPath).sort()) + .toEqual(['focalLength', 'position', 'rotation']); }); it('non-camera recording filters focal samples and commits position/rotation', () => {