From a2bf8726124252d1317849fdf57bec909b692c2a Mon Sep 17 00:00:00 2001 From: Seasons Change <1204992313@qq.com> Date: Fri, 4 Sep 2026 15:31:16 +0800 Subject: [PATCH 1/5] fix(studio): TML-815 explain camera track takeover Multica-Task: TML-815 Multica-Run: d18471e3-5eed-4ad0-ab3b-17f450e8fc3b Co-authored-by: multica-agent --- .git-management.json | 11 +++ .github/PULL_REQUEST_TEMPLATE.md | 17 ++++ e2e/timeline.spec.ts | 41 ++++++++ .../studio/src/components/LumoraStudio.tsx | 2 + .../src/components/editor/EditorViewport.tsx | 21 +--- .../src/components/editor/TimelinePanel.tsx | 91 ++++++++++++++++- .../src/components/editor/camera-drive.ts | 7 +- .../src/components/export/ExportWorkspace.tsx | 7 ++ packages/studio/src/lumora.css | 43 ++++++++ .../studio/test/camera-drive-routing.test.tsx | 98 ++++++++++++++++++- 10 files changed, 314 insertions(+), 24 deletions(-) create mode 100644 .git-management.json create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.git-management.json b/.git-management.json new file mode 100644 index 0000000..facfd67 --- /dev/null +++ b/.git-management.json @@ -0,0 +1,11 @@ +{ + "policy_version": 1, + "default_branch": "main", + "branch_pattern": "^(ai|feat|fix|refactor|chore)/.+$", + "required_pr_metadata": ["task_id", "run_id"], + "required_checks": ["ci", "git-governance"], + "required_approvals": 1, + "require_resolved_conversations": true, + "require_up_to_date_branch": true, + "merge_method": "squash" +} diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4bdc94f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ + + +## 目标 + +## 变更范围 + +## 验收标准 + +- [ ] + +## 测试结果 + +## 风险和回滚 diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 54d83ff..238144e 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -208,6 +208,47 @@ test('idle director view drives the rendered camera and reports its heading', as .not.toBe(initialArrowTransform); }); +test('camera takeover guidance and viewport context-menu suppression follow the active owner', async ({ page }) => { + const viewport = page.getByTestId('lumora-viewport'); + const contextMenuIsPrevented = () => viewport.evaluate((element) => { + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + button: 2, + }); + element.dispatchEvent(event); + return event.defaultPrevented; + }); + + await page.getByTestId('tree-row-sample-camera').click(); + await page.getByTestId('view-mode-select').selectOption('sample-camera'); + const status = page.getByTestId('camera-control-status'); + await expect(status).toContainText('主摄像机推镜'); + await expect(status).toContainText('主摄像机变焦'); + await expect(status).not.toContainText('立方体旋转'); + expect(await contextMenuIsPrevented()).toBe(true); + + await page.getByRole('button', { name: '定位到接管轨道' }).click(); + await expect(page.getByTestId('track-disabled-sample-track-camera-dolly')).toBeFocused(); + await page.getByTestId('track-disabled-sample-track-camera-dolly').check(); + await page.getByTestId('track-disabled-sample-track-camera-focus').check(); + await expect(status).toHaveText('机位“主摄像机”可手动操控。'); + + await page.getByTestId('timeline-play').click(); + await expect(status).toHaveText('播放正在接管机位。暂停播放后可手动操控。'); + expect(await contextMenuIsPrevented()).toBe(true); + await page.getByTestId('timeline-play').click(); + + await page.getByTestId('open-export-workspace').click(); + await expect(page.getByTestId('export-workspace')).toBeVisible(); + const exportStatus = page.getByTestId('export-camera-control-status'); + await expect(exportStatus).toBeVisible(); + await expect(exportStatus).toHaveText( + '导出工作区正在接管视口。关闭工作区即可恢复手动操控;导出进行中请先取消或等待完成。', + ); + expect(await contextMenuIsPrevented()).toBe(true); +}); + test('director free-drive orientation remains stable across pixel and line-mode wheel gestures', async ({ page }) => { const viewport = page.getByTestId('lumora-viewport'); await hideViewportOverlays(page); diff --git a/packages/studio/src/components/LumoraStudio.tsx b/packages/studio/src/components/LumoraStudio.tsx index 01eaf1c..729b42c 100644 --- a/packages/studio/src/components/LumoraStudio.tsx +++ b/packages/studio/src/components/LumoraStudio.tsx @@ -657,6 +657,8 @@ export const LumoraStudio = forwardRef(fu editor={runtime.editor} project={project} selection={editorState.selection} + view={editorState.view} + driveEnabled={!exportOpen} captureRef={captureRef} captureReady={captureReady} captureGeneration={captureGeneration} diff --git a/packages/studio/src/components/editor/EditorViewport.tsx b/packages/studio/src/components/editor/EditorViewport.tsx index 034207d..c94f9dd 100644 --- a/packages/studio/src/components/editor/EditorViewport.tsx +++ b/packages/studio/src/components/editor/EditorViewport.tsx @@ -26,6 +26,7 @@ import { DRIVE_KEY_CODES, getWorldRigidQuaternion, hasSingularWorldTransform, + isCameraTakeoverTrack, restoreObjectOnNode, SINGULAR_CAMERA_WARNING, syncRigidCameraProxy, @@ -261,6 +262,7 @@ export function EditorViewport({ aria-label="3D scene viewport" tabIndex={0} onPointerDown={handlePointerDown} + onContextMenu={(event) => event.preventDefault()} > { endLookGesture(); - suppressNextContextMenu = false; heldKeys.clear(); drive.stop(); attachedId = null; @@ -525,9 +525,7 @@ function useCameraDrive( }; const hasActiveTrack = (cameraId: string): boolean => - !!editor.getProject()?.tracks.some( - (track) => track.objectId === cameraId && track.keyframes.length > 0 && !track.disabled, - ); + !!editor.getProject()?.tracks.some((track) => isCameraTakeoverTrack(track, cameraId)); const canDriveCurrentCamera = (): boolean => { const st = sessionRef.current?.state; @@ -582,7 +580,6 @@ function useCameraDrive( }; const onPointerDown = (event: PointerEvent) => { if (event.button !== 2) return; - suppressNextContextMenu = false; if (lookPointerId !== null) return; const target = event.target; if ( @@ -599,7 +596,6 @@ function useCameraDrive( activityRef.current = true; lookClientX = event.clientX; lookClientY = event.clientY; - suppressNextContextMenu = true; viewportRef.current?.focus({ preventScroll: true }); event.preventDefault(); event.stopPropagation(); @@ -634,11 +630,6 @@ function useCameraDrive( drive.cancelLook(); endLookGesture(); }; - const onContextMenu = (event: MouseEvent) => { - if (!suppressNextContextMenu) return; - event.preventDefault(); - suppressNextContextMenu = false; - }; const onKeyUp = (event: KeyboardEvent) => { if (heldKeys.delete(event.code)) { drive.release(event.code); @@ -680,7 +671,6 @@ function useCameraDrive( viewport?.addEventListener('pointerup', onPointerUp); viewport?.addEventListener('pointercancel', onPointerCancel); viewport?.addEventListener('lostpointercapture', onLostPointerCapture); - viewport?.addEventListener('contextmenu', onContextMenu); const restoreIfNeeded = () => { if (attachedId === null || !attachedNode) return; @@ -695,9 +685,7 @@ function useCameraDrive( // 绑定机位已有启用轨道:节点由轨道求值接管(回放驱动最后一次 apply // 已把播放头时刻的值写到节点),还原静态位姿会让画面与播放头脱节 if ( - project?.tracks.some( - (t) => t.objectId === restoreId && t.keyframes.length > 0 && !t.disabled, - ) + project?.tracks.some((track) => isCameraTakeoverTrack(track, restoreId)) ) { return; } @@ -826,7 +814,6 @@ function useCameraDrive( viewport?.removeEventListener('pointerup', onPointerUp); viewport?.removeEventListener('pointercancel', onPointerCancel); viewport?.removeEventListener('lostpointercapture', onLostPointerCapture); - viewport?.removeEventListener('contextmenu', onContextMenu); restoreIfNeeded(); clearDrive(); }; diff --git a/packages/studio/src/components/editor/TimelinePanel.tsx b/packages/studio/src/components/editor/TimelinePanel.tsx index 518e3b4..d6e4a78 100644 --- a/packages/studio/src/components/editor/TimelinePanel.tsx +++ b/packages/studio/src/components/editor/TimelinePanel.tsx @@ -12,14 +12,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { MAX_TIMELINE_ZOOM, MIN_TIMELINE_ZOOM } from '@lumora/core'; -import type { Project, SceneEditor } from '@lumora/core'; +import type { Project, SceneEditor, ViewState } from '@lumora/core'; import { findObject } from '@lumora/core'; import type { TimelineSession } from '../../hooks/use-timeline-session'; import { projectContentFingerprint } from './timeline-thumbnail-cache'; import { RecordingShortcutSettings } from './RecordingShortcutSettings'; import type { KeyboardShortcut } from './recording-shortcut'; import { DEFAULT_RECORDING_SHORTCUT, formatShortcut } from './recording-shortcut'; -import { CAMERA_DRIVE_LIMITS } from './camera-drive'; +import { CAMERA_DRIVE_LIMITS, isCameraTakeoverTrack } from './camera-drive'; /** 标签列宽度:标尺/轨道/分镜共用,测试与坐标换算引用此常量 */ export const TIMELINE_LABEL_WIDTH = 186; @@ -29,6 +29,9 @@ export interface TimelinePanelProps { editor: SceneEditor; project: Project; selection: string[]; + view?: ViewState; + /** False while a workspace such as export owns the viewport. */ + driveEnabled?: boolean; /** 视口截图通道(FrameCaptureBridge 注册);null = 不可截图(测试/无 Canvas)。 * 可选参数 = 分镜绑定机位 id:传参时按该机位渲染,缺省渲染当前相机 */ captureRef: React.RefObject<((cameraObjectId?: string | null) => string | null) | null>; @@ -79,6 +82,8 @@ export function TimelinePanel({ editor, project, selection, + view = editor.getView(), + driveEnabled = true, captureRef, captureReady, captureGeneration = 0, @@ -97,7 +102,25 @@ export function TimelinePanel({ return object && object.type === 'camera' ? object : null; }, [project, selection]); + const driveCamera = useMemo(() => { + const cameraId = state.recording + ? session.recorder.recordingCameraId + : 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]); + const takeoverTracks = useMemo( + () => driveCamera && !state.recording + ? project.tracks.filter((track) => isCameraTakeoverTrack(track, driveCamera.id)) + : [], + [driveCamera, project.tracks, state.recording], + ); + const bodyRef = useRef(null); + const trackLaneRefs = useRef(new Map()); const rulerRef = useRef(null); const rulerCanvasRef = useRef(null); const [dragSeeking, setDragSeeking] = useState(false); @@ -283,6 +306,52 @@ export function TimelinePanel({ [editor], ); + const locateTakeoverTrack = useCallback(() => { + const firstTrack = takeoverTracks[0]; + if (!firstTrack) return; + const lane = trackLaneRefs.current.get(firstTrack.id); + if (!lane) return; + lane.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }); + lane.querySelector('input[type="checkbox"]')?.focus(); + }, [takeoverTracks]); + + let driveStatus: React.ReactNode; + let driveBlocked = false; + if (!driveEnabled) { + driveBlocked = true; + driveStatus = '导出工作区正在接管视口。关闭导出工作区后可手动操控。'; + } else if (state.overwritePending) { + driveBlocked = true; + driveStatus = '正在等待录制覆盖确认。完成或取消确认后可手动操控。'; + } else if (state.recordingPaused) { + driveBlocked = true; + driveStatus = '录制已暂停。继续录制后可手动操控。'; + } else if (state.playing && !state.recording) { + driveBlocked = true; + driveStatus = '播放正在接管机位。暂停播放后可手动操控。'; + } else if (takeoverTracks.length > 0 && driveCamera) { + driveBlocked = true; + driveStatus = ( + <> + 启用轨道{takeoverTracks.map((track, index) => ( + {index > 0 ? '、' : ''}“{track.name}” + ))}正在接管机位“{driveCamera.name}”。禁用这些轨道后可手动操控。 + + + ); + } else if (driveCamera) { + driveStatus = `机位“${driveCamera.name}”可手动操控。`; + } else { + driveStatus = '导演视图可手动操控。'; + } + const recordClick = () => { if (state.recording) { if (state.recordingPaused) session.resumeRecording(); @@ -343,7 +412,12 @@ export function TimelinePanel({ shortcut={recordingShortcut} onChange={onRecordingShortcutChange} /> -
+
+ 机位操控
{formatTime(time)} @@ -485,6 +566,10 @@ export function TimelinePanel({ project.tracks.map((track) => (
{ + if (node) trackLaneRefs.current.set(track.id, node); + else trackLaneRefs.current.delete(track.id); + }} className={`lumora-timeline__row lumora-timeline__lane${track.disabled ? ' lumora-timeline__lane--disabled' : ''}`} data-testid={`track-lane-${track.id}`} data-track-target-path={track.targetPath} diff --git a/packages/studio/src/components/editor/camera-drive.ts b/packages/studio/src/components/editor/camera-drive.ts index 8fec491..539d28e 100644 --- a/packages/studio/src/components/editor/camera-drive.ts +++ b/packages/studio/src/components/editor/camera-drive.ts @@ -6,7 +6,7 @@ import * as THREE from 'three'; import { focalLengthToFovDeg, fovDegToFocalLength } from '@lumora/core'; -import type { SceneObjectData } from '@lumora/core'; +import type { SceneObjectData, TrackData } from '@lumora/core'; import { applyTransform } from './scene-builder'; export type CameraControlMode = 'keyboard-mouse' | 'keyboard-only'; @@ -47,6 +47,11 @@ export const CAMERA_DRIVE_LIMITS = Object.freeze({ smoothing: Object.freeze({ min: 1, max: 30 }), }); +/** A non-empty enabled track owns its bound camera channel during playback/seek. */ +export function isCameraTakeoverTrack(track: TrackData, cameraId: string): boolean { + return track.objectId === cameraId && track.keyframes.length > 0 && !track.disabled; +} + function bounded(value: number | undefined, fallback: number, min: number, max: number): number { return typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) diff --git a/packages/studio/src/components/export/ExportWorkspace.tsx b/packages/studio/src/components/export/ExportWorkspace.tsx index ccbeab9..8886c96 100644 --- a/packages/studio/src/components/export/ExportWorkspace.tsx +++ b/packages/studio/src/components/export/ExportWorkspace.tsx @@ -584,6 +584,13 @@ export function ExportWorkspace({

{selectedShots.length} 个分镜 · {selectedDuration.toFixed(2)} 秒

+

+ 导出工作区正在接管视口。关闭工作区即可恢复手动操控;导出进行中请先取消或等待完成。 +

- - ); - } else if (driveCamera) { - driveStatus = `机位“${driveCamera.name}”可手动操控。`; - } else { - driveStatus = '导演视图可手动操控。'; - } + }, [blockers]); + const driveStatus = blockers.length > 0 + ? blockers.map((blocker) => ( + + {blocker.message} + {blocker.kind === 'tracks' && ( + + )} + + )) + : driveCamera + ? `机位“${driveCamera.name}”可手动操控。` + : '导演视图可手动操控。'; const recordClick = () => { if (state.recording) { @@ -415,9 +406,10 @@ export function TimelinePanel({
- 机位操控 + 机位操控