Skip to content
Open
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
94 changes: 94 additions & 0 deletions e2e/timeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Page['getByTestId']>,
Expand Down Expand Up @@ -337,6 +349,88 @@ 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('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();
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 initialPose = await objectPose(page, 'sample-cube');
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(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();
});

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');
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/editor/scene-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions packages/studio/src/components/LumoraStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -519,8 +519,8 @@ export const LumoraStudio = forwardRef<LumoraStudioHandle, LumoraStudioProps>(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') {
Expand Down
26 changes: 18 additions & 8 deletions packages/studio/src/components/editor/EditorViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
});
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -1175,9 +1179,15 @@ function CameraPoseReadout({
if (!root || !project) return;
const poses: Record<string, unknown> = {};
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<string, unknown>).focalLength;
poses[object.id] = {
position: [node.position.x, node.position.y, node.position.z],
Expand Down
6 changes: 3 additions & 3 deletions packages/studio/src/components/editor/PlaybackDriver.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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);
Expand Down
48 changes: 29 additions & 19 deletions packages/studio/src/components/editor/TimelinePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const trackLaneRefs = useRef(new Map<string, HTMLDivElement>());
const rulerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -339,8 +338,8 @@ export function TimelinePanel({
)}
</span>
))
: driveCamera
? `机位“${driveCamera.name}”可手动操控。`
: driveObject
? `${driveObject.type === 'camera' ? '机位' : '对象'}“${driveObject.name}”可手动操控。`
: '导演视图可手动操控。';

const recordClick = () => {
Expand All @@ -349,7 +348,7 @@ export function TimelinePanel({
else session.stopRecording();
return;
}
if (selectedCamera) session.startRecording(selectedCamera.id);
if (selectedObject) session.startRecording(selectedObject.id);
};

// 覆盖确认模态:真模态语义 —— 初始聚焦首个可聚焦项(容器不再拿焦点,消除
Expand Down Expand Up @@ -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 ? '■' : '●'}
Expand Down Expand Up @@ -481,6 +480,17 @@ export function TimelinePanel({
{state.cameraControls.mouseSensitivity.toFixed(1)}
</span>
</label>
<label className="lumora-check lumora-camera-controls__invert">
<input
type="checkbox"
aria-label="鼠标垂直反转"
data-testid="camera-control-invert-y"
checked={state.cameraControls.invertMouseY}
disabled={state.cameraControls.mode === 'keyboard-only'}
onChange={(event) => session.setCameraControlSettings({ invertMouseY: event.target.checked })}
/>
鼠标垂直反转
</label>
<span
className={`lumora-camera-controls__status${driveBlocked ? ' lumora-camera-controls__status--blocked' : ''}`}
data-testid="camera-control-status"
Expand Down
Loading
Loading