diff --git a/e2e/export.spec.ts b/e2e/export.spec.ts index a341ba7..cec7d12 100644 --- a/e2e/export.spec.ts +++ b/e2e/export.spec.ts @@ -1342,6 +1342,10 @@ test('proves live camera drive sensitivity before isolating editor shortcuts whi await page.getByRole('button', { name: '导出 WebM' }).click(); await expect(page.getByLabel('导出进度')).not.toHaveJSProperty('value', 0); + await expect(page.getByRole('button', { name: '关闭导出' })).toBeDisabled(); + await expect(page.getByTestId('export-camera-control-status')).toContainText( + '先取消或等待完成,再关闭导出', + ); await pressEditorShortcuts(); await expect(page.getByTestId('command-palette')).toHaveCount(0); await expect(rows).toHaveCount(rowCount); diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 54d83ff..8684d24 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -62,10 +62,25 @@ async function focusViewportByKeyboard(page: Page): Promise { /** 画布截图:先等一帧渲染(seek/暂停后场景经 rAF 重绘) */ async function canvasShot(page: Page): Promise { + await waitForStableFrame(page); await page.waitForTimeout(120); return page.locator('.lumora-viewport canvas').screenshot(); } +async function waitForStableFrame(page: Page, frameCount = 2): Promise { + await page.evaluate(async (count) => { + await new Promise((resolve) => { + let remaining = Math.max(1, count); + const tick = () => { + remaining -= 1; + if (remaining <= 0) resolve(); + else requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + }, frameCount); +} + /** 播放头横向位置(行内 px,含标签列):186 + time * zoom */ async function playheadPx(page: Page): Promise { return page.getByTestId('timeline-playhead').evaluate((el) => parseFloat((el as HTMLElement).style.left)); @@ -98,6 +113,7 @@ async function cameraPose( page: Page, cameraId = 'sample-camera', ): Promise<{ position: [number, number, number]; rotation: [number, number, number]; focalLength: number | null }> { + await waitForStableFrame(page); const text = await page.getByTestId('camera-pose-readout').textContent(); if (!text) throw new Error('位姿读取钩子不可用'); const pose = JSON.parse(text)[cameraId]; @@ -105,6 +121,89 @@ async function cameraPose( return pose; } +async function stableCameraPose( + page: Page, + cameraId = 'sample-camera', +): ReturnType { + let previous = await cameraPose(page, cameraId); + let stableSamples = 0; + for (let attempt = 0; attempt < 90; attempt += 1) { + const current = await cameraPose(page, cameraId); + const focalDelta = Math.abs((current.focalLength ?? 0) - (previous.focalLength ?? 0)); + if ( + vectorDistance(current.position, previous.position) < 0.00001 && + quaternionAngle(current.rotation, previous.rotation) < 0.00001 && + focalDelta < 0.00001 + ) { + stableSamples += 1; + if (stableSamples >= 2) return current; + } else { + stableSamples = 0; + } + previous = current; + } + throw new Error(`机位 ${cameraId} 未在稳定帧窗口内静止`); +} + +async function rightDrag( + page: Page, + viewport: ReturnType, + options: { leaveViewport?: boolean } = {}, +): Promise { + const viewportBounds = await viewport.boundingBox(); + if (!viewportBounds) throw new Error('viewport is unavailable'); + const pageViewport = page.viewportSize(); + if (!pageViewport) throw new Error('page viewport is unavailable'); + const contextMenu = page.evaluate(() => new Promise((resolve) => { + const handler = (event: MouseEvent) => { + cleanup(); + resolve(event.defaultPrevented); + }; + const timeout = window.setTimeout(() => { + cleanup(); + resolve(false); + }, 1_000); + const cleanup = () => { + window.clearTimeout(timeout); + document.removeEventListener('contextmenu', handler); + }; + document.addEventListener('contextmenu', handler); + })); + const visibleLeft = Math.max(0, viewportBounds.x); + const visibleTop = Math.max(0, viewportBounds.y); + const visibleRight = Math.min(pageViewport.width, viewportBounds.x + viewportBounds.width); + const visibleBottom = Math.min(pageViewport.height, viewportBounds.y + viewportBounds.height); + if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) throw new Error('viewport has no visible hit area'); + const startX = visibleLeft + (visibleRight - visibleLeft) * 0.42; + const startY = visibleTop + (visibleBottom - visibleTop) * 0.52; + await page.mouse.move(startX, startY); + await page.mouse.down({ button: 'right' }); + await page.mouse.move( + startX + viewportBounds.width * 0.18, + startY - viewportBounds.height * 0.1, + { steps: 8 }, + ); + if (options.leaveViewport) { + const toolbarBounds = await page.getByTestId('lumora-toolbar').boundingBox(); + if (!toolbarBounds) throw new Error('toolbar is unavailable'); + await page.mouse.move(toolbarBounds.x + 8, toolbarBounds.y + toolbarBounds.height / 2, { steps: 4 }); + } + await page.mouse.up({ button: 'right' }); + await waitForStableFrame(page); + return contextMenu; +} + +async function moveReactRootIntoOpenShadowRoot(page: Page): Promise { + await page.evaluate(() => { + const reactRoot = document.getElementById('root'); + if (!reactRoot) throw new Error('React root is missing'); + const host = document.createElement('div'); + host.dataset.testid = 'e2e-shadow-host'; + document.body.append(host); + host.attachShadow({ mode: 'open' }).append(reactRoot); + }); +} + function vectorDistance(a: readonly number[], b: readonly number[]): number { return Math.hypot(...a.map((value, index) => value - (b[index] ?? 0))); } @@ -208,6 +307,198 @@ 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'); + + 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 rightDrag(page, viewport)).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).toContainText('播放正在接管机位;暂停播放后可手动操控。'); + expect(await rightDrag(page, viewport)).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).toContainText('导出工作区正在接管视口;关闭导出工作区后可手动操控。'); + expect(await rightDrag(page, viewport)).toBe(true); +}); + +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'); + await page.getByTestId('tree-row-sample-camera').click(); + await page.getByTestId('view-mode-select').selectOption('sample-camera'); + const dolly = page.getByTestId('track-disabled-sample-track-camera-dolly'); + const focus = page.getByTestId('track-disabled-sample-track-camera-focus'); + + await dolly.check(); + await focus.check(); + await expect(status).toHaveText('机位“主摄像机”可手动操控。'); + const freeBefore = await stableCameraPose(page); + expect(await rightDrag(page, viewport)).toBe(true); + const freeAfter = await stableCameraPose(page); + expect(vectorDistance(freeAfter.position, freeBefore.position)).toBeLessThan(0.000001); + expect(quaternionAngle(freeAfter.rotation, freeBefore.rotation)).toBeGreaterThan(0.001); + + await dolly.uncheck(); + await focus.uncheck(); + const trackBefore = await stableCameraPose(page); + expect(await rightDrag(page, viewport)).toBe(true); + const trackAfter = await stableCameraPose(page); + expect(vectorDistance(trackAfter.position, trackBefore.position)).toBeLessThan(0.000001); + expect(quaternionAngle(trackAfter.rotation, trackBefore.rotation)).toBeLessThan(0.000001); + + await page.getByTestId('view-mode-select').selectOption('director'); + await page.getByTestId('timeline-play').click(); + const playbackBefore = await stableCameraPose(page, '__rendered__'); + expect(await rightDrag(page, viewport)).toBe(true); + const playbackAfter = await stableCameraPose(page, '__rendered__'); + expect(vectorDistance(playbackAfter.position, playbackBefore.position)).toBeLessThan(0.000001); + expect(quaternionAngle(playbackAfter.rotation, playbackBefore.rotation)).toBeLessThan(0.000001); + await page.getByTestId('timeline-play').click(); + + const resumedBefore = await stableCameraPose(page, '__rendered__'); + expect(await rightDrag(page, viewport)).toBe(true); + const resumedAfter = await stableCameraPose(page, '__rendered__'); + expect(quaternionAngle(resumedAfter.rotation, resumedBefore.rotation)).toBeGreaterThan(0.001); + + await page.getByTestId('open-export-workspace').click(); + await expect(page.getByTestId('export-workspace')).toBeVisible(); + const viewportBounds = await viewport.boundingBox(); + if (!viewportBounds) throw new Error('viewport is unavailable under export overlay'); + const exportOverlayOwnsViewportPoint = await page.evaluate(({ x, y }) => { + const hit = document.elementFromPoint(x, y); + return hit?.closest('[data-testid="export-workspace"]') !== null; + }, { + x: viewportBounds.x + viewportBounds.width * 0.42, + y: viewportBounds.y + viewportBounds.height * 0.52, + }); + expect(exportOverlayOwnsViewportPoint).toBe(true); + const exportBefore = await stableCameraPose(page, '__rendered__'); + expect(await rightDrag(page, viewport)).toBe(true); + const exportAfter = await stableCameraPose(page, '__rendered__'); + expect(vectorDistance(exportAfter.position, exportBefore.position)).toBeLessThan(0.000001); + expect(quaternionAngle(exportAfter.rotation, exportBefore.rotation)).toBeLessThan(0.000001); + + await page.getByRole('button', { name: '关闭导出' }).click(); + const outOfViewportBefore = await stableCameraPose(page, '__rendered__'); + expect(await rightDrag(page, viewport, { leaveViewport: true })).toBe(true); + const outOfViewportAfter = await stableCameraPose(page, '__rendered__'); + expect(quaternionAngle(outOfViewportAfter.rotation, outOfViewportBefore.rotation)).toBeGreaterThan(0.001); +}); + +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'); + await expect(page.getByTestId('camera-control-status')).toContainText('主摄像机推镜'); + await moveReactRootIntoOpenShadowRoot(page); + const viewport = page.getByTestId('lumora-viewport'); + await expect(viewport).toBeVisible(); + await waitForStableFrame(page); + expect(await rightDrag(page, viewport, { leaveViewport: true })).toBe(true); +}); + +test('does not expire a long-held right-drag before releasing outside the viewport', async ({ page }) => { + await page.getByTestId('tree-row-sample-camera').click(); + await page.getByTestId('view-mode-select').selectOption('sample-camera'); + await expect(page.getByTestId('camera-control-status')).toContainText('主摄像机推镜'); + const viewport = page.getByTestId('lumora-viewport'); + const bounds = await viewport.boundingBox(); + const toolbar = await page.getByTestId('lumora-toolbar').boundingBox(); + if (!bounds || !toolbar) throw new Error('viewport or toolbar is unavailable'); + const contextMenu = page.evaluate(() => new Promise((resolve) => { + const handler = (event: MouseEvent) => { + document.removeEventListener('contextmenu', handler); + resolve(event.defaultPrevented); + }; + document.addEventListener('contextmenu', handler); + window.setTimeout(() => { + document.removeEventListener('contextmenu', handler); + resolve(false); + }, 4_000); + })); + const startX = bounds.x + bounds.width * 0.5; + const startY = bounds.y + bounds.height * 0.5; + await page.mouse.move(startX, startY); + await page.mouse.down({ button: 'right' }); + await page.waitForTimeout(2_300); + await page.mouse.move(toolbar.x + 8, toolbar.y + toolbar.height / 2, { steps: 4 }); + await page.mouse.up({ button: 'right' }); + expect(await contextMenu).toBe(true); +}); + +test('allows a new outside right-click after an orphaned viewport pointerdown', async ({ page }) => { + await page.getByTestId('tree-row-sample-camera').click(); + await page.getByTestId('view-mode-select').selectOption('sample-camera'); + await expect(page.getByTestId('camera-control-status')).toContainText('主摄像机推镜'); + const viewport = page.getByTestId('lumora-viewport'); + const viewportBounds = await viewport.boundingBox(); + const toolbarBounds = await page.getByTestId('lumora-toolbar').boundingBox(); + if (!viewportBounds || !toolbarBounds) throw new Error('viewport or toolbar is unavailable'); + await page.evaluate(() => { + const scope = globalThis as typeof globalThis & { + __lumoraOutsidePointerDowns?: number; + __lumoraOutsideContextMenus?: boolean[]; + }; + scope.__lumoraOutsidePointerDowns = 0; + scope.__lumoraOutsideContextMenus = []; + window.addEventListener('pointerdown', (event) => { + const target = event.composedPath()[0]; + if (target instanceof Element && target.closest('[data-testid="lumora-toolbar"]')) { + scope.__lumoraOutsidePointerDowns! += 1; + } + }, true); + window.addEventListener('contextmenu', (event) => { + const target = event.composedPath()[0]; + if (target instanceof Element && target.closest('[data-testid="lumora-toolbar"]')) { + scope.__lumoraOutsideContextMenus!.push(event.defaultPrevented); + } + }); + }); + + // Seed the abnormal state directly: a real browser cannot begin a second + // physical right-click until the first button is released, while the bug is + // specifically that the application missed that terminating event. + await viewport.dispatchEvent('pointerdown', { + bubbles: true, + composed: true, + pointerId: 91, + pointerType: 'mouse', + button: 2, + buttons: 2, + clientX: viewportBounds.x + viewportBounds.width * 0.5, + clientY: viewportBounds.y + viewportBounds.height * 0.5, + }); + await page.mouse.click(toolbarBounds.x + 8, toolbarBounds.y + toolbarBounds.height / 2, { button: 'right' }); + + const result = await page.evaluate(() => { + const scope = globalThis as typeof globalThis & { + __lumoraOutsidePointerDowns?: number; + __lumoraOutsideContextMenus?: boolean[]; + }; + return { + pointerDowns: scope.__lumoraOutsidePointerDowns ?? 0, + contextMenus: scope.__lumoraOutsideContextMenus ?? [], + }; + }); + expect(result.pointerDowns).toBeGreaterThan(0); + expect(result.contextMenus).toContain(false); +}); + 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); @@ -940,6 +1231,30 @@ test('recording viewport remains operable at 760px and 375px and right drag surv expect(vectorDistance(afterDrag.rotation, beforeDrag.rotation)).toBeGreaterThan(0.03); }); +test('embedded medium stage keeps a visible scene beside aggregate camera guidance', async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 600 }); + const studio = page.getByTestId('lumora-studio'); + await studio.evaluate((element) => { + const node = element as HTMLElement; + node.style.width = '684px'; + node.style.height = '600px'; + }); + await page.getByTestId('tree-row-sample-camera').click(); + await page.getByTestId('view-mode-select').selectOption('sample-camera'); + await page.getByTestId('timeline-play').click(); + await page.waitForTimeout(120); + + const measurements = await page.evaluate(() => { + const stage = document.querySelector('.lumora-studio__stage')!.getBoundingClientRect(); + const scene = document.querySelector('.lumora-studio__scene-slot')!.getBoundingClientRect(); + const status = document.querySelector('[data-testid="camera-control-status"]')!.getBoundingClientRect(); + return { stageWidth: stage.width, sceneHeight: scene.height, statusBottom: status.bottom, sceneBottom: scene.bottom }; + }); + expect(measurements.stageWidth).toBeLessThan(900); + expect(measurements.sceneHeight).toBeGreaterThanOrEqual(280); + expect(measurements.statusBottom).toBeLessThanOrEqual(measurements.sceneBottom + 320); +}); + test('recording control: keyboard-only mode ignores pointer look and records arrow rotation', async ({ page }) => { await page.getByRole('button', { name: '纯键盘操控' }).click(); await startRecording(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..5919dba 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, @@ -73,6 +74,106 @@ export function findObjectId(object: THREE.Object3D): string | null { return resolveOwnedIdAboveContent(object); } +/** Keep a right-button gesture owned by the viewport through an out-of-bounds release. */ +function useViewportContextMenuGuard(viewportRef: React.RefObject): void { + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + let gesturePointerId: number | null = null; + let gestureExpiryTimer: number | null = null; + let pendingContextMenu = false; + let pendingContextMenuTimer: number | null = null; + + const clearGestureExpiry = () => { + if (gestureExpiryTimer !== null) { + globalThis.clearTimeout(gestureExpiryTimer); + gestureExpiryTimer = null; + } + }; + const clearPendingContextMenu = () => { + pendingContextMenu = false; + if (pendingContextMenuTimer !== null) { + globalThis.clearTimeout(pendingContextMenuTimer); + pendingContextMenuTimer = null; + } + }; + const clearAll = () => { + gesturePointerId = null; + clearGestureExpiry(); + clearPendingContextMenu(); + }; + const armGesture = (pointerId: number) => { + clearPendingContextMenu(); + gesturePointerId = pointerId; + clearGestureExpiry(); + // A stuck pointer stream is abnormal; normal long presses remain armed + // until pointerup/pointercancel rather than expiring after two seconds. + gestureExpiryTimer = globalThis.setTimeout(() => { + gesturePointerId = null; + gestureExpiryTimer = null; + }, 5 * 60_000); + }; + const eventPath = (event: Event): EventTarget[] => { + const path = event.composedPath?.(); + return path && path.length > 0 ? path : event.target ? [event.target] : []; + }; + const isWithinViewport = (event: Event): boolean => { + const path = eventPath(event); + if (path.includes(viewport)) return true; + const target = event.target; + return target instanceof Node && (target === viewport || viewport.contains(target)); + }; + const isInteractiveTarget = (event: Event): boolean => eventPath(event).some((entry) => { + if (!(entry instanceof Element)) return false; + return entry.matches('button, input, select, textarea, [contenteditable="true"]') || + entry.closest('button, input, select, textarea, [contenteditable="true"]') !== null; + }); + const onPointerDown = (event: PointerEvent) => { + // Every pointer sequence supersedes any stale gesture state before the + // current event is evaluated for viewport context-menu suppression. + clearAll(); + if (event.button !== 2 || !isWithinViewport(event) || isInteractiveTarget(event)) return; + armGesture(event.pointerId); + }; + const onPointerUp = (event: PointerEvent) => { + if (event.pointerId !== gesturePointerId) return; + gesturePointerId = null; + clearGestureExpiry(); + pendingContextMenu = true; + if (pendingContextMenuTimer !== null) globalThis.clearTimeout(pendingContextMenuTimer); + pendingContextMenuTimer = globalThis.setTimeout(clearPendingContextMenu, 10_000); + }; + const onPointerCancel = (event: PointerEvent) => { + if (event.pointerId === gesturePointerId) clearAll(); + }; + const onContextMenu = (event: MouseEvent) => { + if (gesturePointerId !== null || pendingContextMenu || isWithinViewport(event)) { + event.preventDefault(); + clearAll(); + } + }; + const onWindowBlur = () => clearAll(); + const onVisibilityChange = () => { + if (document.visibilityState === 'hidden') clearAll(); + }; + window.addEventListener('pointerdown', onPointerDown, true); + window.addEventListener('pointerup', onPointerUp, true); + window.addEventListener('pointercancel', onPointerCancel, true); + window.addEventListener('contextmenu', onContextMenu, true); + window.addEventListener('blur', onWindowBlur); + document.addEventListener('visibilitychange', onVisibilityChange); + return () => { + clearAll(); + window.removeEventListener('pointerdown', onPointerDown, true); + window.removeEventListener('pointerup', onPointerUp, true); + window.removeEventListener('pointercancel', onPointerCancel, true); + window.removeEventListener('contextmenu', onContextMenu, true); + window.removeEventListener('blur', onWindowBlur); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, [viewportRef]); +} + function normalizeEulerSignedZero(euler: THREE.Euler): void { const x = Object.is(euler.x, -0) ? 0 : euler.x; const y = Object.is(euler.y, -0) ? 0 : euler.y; @@ -106,6 +207,7 @@ export function EditorViewport({ liveTransformStore, }: EditorViewportProps) { const containerRef = useRef(null); + useViewportContextMenuGuard(containerRef); const rootRef = useRef(null); const [sceneRootGeneration, setSceneRootGeneration] = useState(0); const cameraRef = useRef(null); @@ -261,6 +363,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 +626,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 +681,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 +697,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 +731,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 +772,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 +786,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 +915,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..e4ada6b 100644 --- a/packages/studio/src/components/editor/TimelinePanel.tsx +++ b/packages/studio/src/components/editor/TimelinePanel.tsx @@ -10,16 +10,16 @@ * 区块与播放头全部以 `time * zoom` 在同一坐标内定位,滚动同步、无 186px 错位。 */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, 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, getCameraDriveBlockers } 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, @@ -86,6 +91,7 @@ export function TimelinePanel({ onRecordingShortcutChange = () => false, }: TimelinePanelProps) { const { timeline, state } = session; + const cameraControlsTitleId = useId(); const time = usePlayheadTime(session); const zoom = state.zoom; const zoomRef = useRef(zoom); @@ -97,7 +103,18 @@ 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 bodyRef = useRef(null); + const trackLaneRefs = useRef(new Map()); const rulerRef = useRef(null); const rulerCanvasRef = useRef(null); const [dragSeeking, setDragSeeking] = useState(false); @@ -283,6 +300,49 @@ export function TimelinePanel({ [editor], ); + const blockers = useMemo( + () => getCameraDriveBlockers({ + driveEnabled, + overwritePending: state.overwritePending, + recordingPaused: state.recordingPaused, + playing: state.playing, + recording: state.recording, + cameraId: driveCamera?.id ?? null, + cameraName: driveCamera?.name ?? null, + tracks: project.tracks, + }), + [driveCamera, driveEnabled, project.tracks, state.overwritePending, state.playing, state.recording, state.recordingPaused], + ); + const driveBlocked = blockers.length > 0; + const locateTakeoverTrack = useCallback(() => { + const firstTrack = blockers.find((blocker) => blocker.kind === 'tracks')?.tracks?.[0]; + if (!firstTrack) return; + const lane = trackLaneRefs.current.get(firstTrack.id); + if (!lane) return; + const prefersReducedMotion = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + lane.scrollIntoView?.({ block: 'nearest', behavior: prefersReducedMotion ? 'auto' : 'smooth' }); + lane.querySelector('input[type="checkbox"]')?.focus(); + }, [blockers]); + const driveStatus = blockers.length > 0 + ? blockers.map((blocker) => ( + + {blocker.message} + {blocker.kind === 'tracks' && ( + + )} + + )) + : driveCamera + ? `机位“${driveCamera.name}”可手动操控。` + : '导演视图可手动操控。'; + const recordClick = () => { if (state.recording) { if (state.recordingPaused) session.resumeRecording(); @@ -343,7 +403,13 @@ export function TimelinePanel({ shortcut={recordingShortcut} onChange={onRecordingShortcutChange} /> -
+
+ 机位操控
{formatTime(time)} @@ -485,6 +558,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..c908257 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,77 @@ 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; +} + +export interface CameraDriveBlockerInput { + driveEnabled: boolean; + /** Whether the export operation is actively running (the workspace cannot close yet). */ + exportRunning?: boolean; + overwritePending: boolean; + recordingPaused: boolean; + playing: boolean; + recording: boolean; + cameraId: string | null; + cameraName: string | null; + tracks: readonly TrackData[]; +} + +export interface CameraDriveBlocker { + kind: 'export' | 'overwrite' | 'recording-paused' | 'playback' | 'tracks'; + message: string; + tracks?: readonly TrackData[]; +} + +/** + * Resolve every active owner of manual camera input. The list is deliberately + * independent: clearing one owner must not promise that another owner is gone. + */ +export function getCameraDriveBlockers(input: CameraDriveBlockerInput): CameraDriveBlocker[] { + const blockers: CameraDriveBlocker[] = []; + if (!input.driveEnabled) { + blockers.push({ + kind: 'export', + message: input.exportRunning + ? '导出正在运行;请先取消或等待完成,再关闭导出,之后可手动操控。' + : '导出工作区正在接管视口;关闭导出工作区后可手动操控。', + }); + } + if (input.overwritePending) { + blockers.push({ + kind: 'overwrite', + message: '正在等待录制覆盖确认;完成或放弃确认后可手动操控。', + }); + } + if (input.recordingPaused) { + blockers.push({ + kind: 'recording-paused', + message: '录制已暂停;继续录制或停止录制后可手动操控。', + }); + } + if (input.playing && !input.recording) { + blockers.push({ + kind: 'playback', + message: '播放正在接管机位;暂停播放后可手动操控。', + }); + } + if (!input.recording && input.cameraId && input.cameraName) { + const takeoverTracks = input.tracks.filter((track) => isCameraTakeoverTrack(track, input.cameraId!)); + if (takeoverTracks.length > 0) { + blockers.push({ + kind: 'tracks', + tracks: takeoverTracks, + message: + `启用轨道${takeoverTracks.map((track) => `“${track.name}”`).join('、')}` + + `正在接管机位“${input.cameraName}”;禁用这些轨道后可手动操控。`, + }); + } + } + return blockers; +} + 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..f89c8f1 100644 --- a/packages/studio/src/components/export/ExportWorkspace.tsx +++ b/packages/studio/src/components/export/ExportWorkspace.tsx @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import type { RefObject } from 'react'; +import { findObject } from '@lumora/core'; import type { Project, ShotClipData } from '@lumora/core'; import type { StudioRuntime } from '../../runtime/studio-runtime'; import type { TimelineSession } from '../../hooks/use-timeline-session'; @@ -21,6 +22,7 @@ import type { WebmSupport, WebmSupportProbe, } from '../../export/preview-export'; +import { getCameraDriveBlockers } from '../editor/camera-drive'; export type ThumbnailCapture = (cameraObjectId?: string | null) => string | null; export type ExportFrameCapture = ProjectFrameCapture; @@ -119,6 +121,8 @@ export function ExportWorkspace({ supportProbe, recordingDependencies, }: ExportWorkspaceProps) { + const workspaceTitleId = useId(); + const cameraStatusId = useId(); useEventRefresh(runtime.events, ['contribution:changed', 'plugin:state-changed']); const [range, setRange] = useState('all'); const [resolution, setResolution] = useState('720p'); @@ -153,6 +157,28 @@ export function ExportWorkspace({ ? detectedSupport.support : CHECKING_WEBM_SUPPORT ); + const busy = activeOperation !== null; + const running = activeOperation?.kind === 'webm'; + const view = runtime.editor.getView(); + const povCamera = useMemo(() => { + if (view.viewMode === 'director') return null; + const camera = findObject(project, view.viewMode.cameraObjectId); + return camera?.type === 'camera' ? camera : null; + }, [project, view.viewMode]); + const cameraDriveBlockers = useMemo( + () => getCameraDriveBlockers({ + driveEnabled: false, + exportRunning: running, + overwritePending: session.state.overwritePending, + recordingPaused: session.state.recordingPaused, + playing: session.state.playing, + recording: session.state.recording, + cameraId: povCamera?.id ?? null, + cameraName: povCamera?.name ?? null, + tracks: project.tracks, + }), + [povCamera, project.tracks, running, session.state.overwritePending, session.state.playing, session.state.recording, session.state.recordingPaused], + ); useEffect(() => { if (supportOverride) return; @@ -340,8 +366,6 @@ export function ExportWorkspace({ 0, ); const exporters = runtime.host.contributions.getExporters(); - const busy = activeOperation !== null; - const running = activeOperation?.kind === 'webm'; const aspect = project.settings.aspect[0] / project.settings.aspect[1]; const liveStatus = liveAnnouncement.status; const politeLiveMessage = liveStatus.kind === 'error' @@ -576,14 +600,32 @@ export function ExportWorkspace({
event.preventDefault()} >
-

导出

+

导出

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

+

+ {cameraDriveBlockers.map((blocker) => ( + + {blocker.message} + + ))} +