Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions e2e/export.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
315 changes: 315 additions & 0 deletions e2e/timeline.spec.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/studio/src/components/LumoraStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,8 @@ export const LumoraStudio = forwardRef<LumoraStudioHandle, LumoraStudioProps>(fu
editor={runtime.editor}
project={project}
selection={editorState.selection}
view={editorState.view}
driveEnabled={!exportOpen}
captureRef={captureRef}
captureReady={captureReady}
captureGeneration={captureGeneration}
Expand Down
122 changes: 105 additions & 17 deletions packages/studio/src/components/editor/EditorViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
DRIVE_KEY_CODES,
getWorldRigidQuaternion,
hasSingularWorldTransform,
isCameraTakeoverTrack,
restoreObjectOnNode,
SINGULAR_CAMERA_WARNING,
syncRigidCameraProxy,
Expand Down Expand Up @@ -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<HTMLElement | null>): 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;
Expand Down Expand Up @@ -106,6 +207,7 @@ export function EditorViewport({
liveTransformStore,
}: EditorViewportProps) {
const containerRef = useRef<HTMLDivElement>(null);
useViewportContextMenuGuard(containerRef);
const rootRef = useRef<THREE.Group | null>(null);
const [sceneRootGeneration, setSceneRootGeneration] = useState(0);
const cameraRef = useRef<THREE.Camera | null>(null);
Expand Down Expand Up @@ -261,6 +363,7 @@ export function EditorViewport({
aria-label="3D scene viewport"
tabIndex={0}
onPointerDown={handlePointerDown}
onContextMenu={(event) => event.preventDefault()}
>
<Canvas
dpr={[1, 2]}
Expand Down Expand Up @@ -430,7 +533,6 @@ function useCameraDrive(
let lookPointerId: number | null = null;
let lookClientX = 0;
let lookClientY = 0;
let suppressNextContextMenu = false;
const previousPrimaryPosition = new THREE.Vector3();
const previousPrimaryQuaternion = new THREE.Quaternion();
const currentPrimaryPosition = new THREE.Vector3();
Expand All @@ -453,7 +555,6 @@ function useCameraDrive(

const clearDrive = () => {
endLookGesture();
suppressNextContextMenu = false;
heldKeys.clear();
drive.stop();
attachedId = null;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -826,7 +915,6 @@ function useCameraDrive(
viewport?.removeEventListener('pointerup', onPointerUp);
viewport?.removeEventListener('pointercancel', onPointerCancel);
viewport?.removeEventListener('lostpointercapture', onLostPointerCapture);
viewport?.removeEventListener('contextmenu', onContextMenu);
restoreIfNeeded();
clearDrive();
};
Expand Down
85 changes: 81 additions & 4 deletions packages/studio/src/components/editor/TimelinePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>;
Expand Down Expand Up @@ -79,13 +82,16 @@ export function TimelinePanel({
editor,
project,
selection,
view = editor.getView(),
driveEnabled = true,
captureRef,
captureReady,
captureGeneration = 0,
recordingShortcut = DEFAULT_RECORDING_SHORTCUT,
onRecordingShortcutChange = () => false,
}: TimelinePanelProps) {
const { timeline, state } = session;
const cameraControlsTitleId = useId();
const time = usePlayheadTime(session);
const zoom = state.zoom;
const zoomRef = useRef(zoom);
Expand All @@ -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<HTMLDivElement>(null);
const trackLaneRefs = useRef(new Map<string, HTMLDivElement>());
const rulerRef = useRef<HTMLDivElement>(null);
const rulerCanvasRef = useRef<HTMLDivElement>(null);
const [dragSeeking, setDragSeeking] = useState(false);
Expand Down Expand Up @@ -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<HTMLInputElement>('input[type="checkbox"]')?.focus();
}, [blockers]);
const driveStatus = blockers.length > 0
? blockers.map((blocker) => (
<span className="lumora-camera-controls__blocker" key={blocker.kind}>
{blocker.message}
{blocker.kind === 'tracks' && (
<button
type="button"
className="lumora-camera-controls__locate"
aria-label="定位轨道:禁用接管轨道"
onClick={locateTakeoverTrack}
>
定位轨道
</button>
)}
</span>
))
: driveCamera
? `机位“${driveCamera.name}”可手动操控。`
: '导演视图可手动操控。';

const recordClick = () => {
if (state.recording) {
if (state.recordingPaused) session.resumeRecording();
Expand Down Expand Up @@ -343,7 +403,13 @@ export function TimelinePanel({
shortcut={recordingShortcut}
onChange={onRecordingShortcutChange}
/>
<div className="lumora-camera-controls" data-testid="camera-control-settings">
<div
className="lumora-camera-controls"
data-testid="camera-control-settings"
role="group"
aria-labelledby={cameraControlsTitleId}
>
<span id={cameraControlsTitleId} className="lumora-camera-controls__title">机位操控</span>
<div className="lumora-camera-controls__modes" role="group" aria-label="机位操控模式">
<button
type="button"
Expand Down Expand Up @@ -415,6 +481,13 @@ export function TimelinePanel({
{state.cameraControls.mouseSensitivity.toFixed(1)}
</span>
</label>
<span
className={`lumora-camera-controls__status${driveBlocked ? ' lumora-camera-controls__status--blocked' : ''}`}
data-testid="camera-control-status"
aria-live="polite"
>
{driveStatus}
</span>
</div>
<span className="lumora-timeline__time" data-testid="timeline-time">
{formatTime(time)}
Expand Down Expand Up @@ -485,6 +558,10 @@ export function TimelinePanel({
project.tracks.map((track) => (
<div
key={track.id}
ref={(node) => {
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}
Expand Down
Loading
Loading