Files
ttstd-3d-director/src/editor/panels/CameraPanel.test.tsx
tongtongstudio 89ac44c418 feat(editor): 机位画幅遮幅与监视器全屏对齐
主视口机位视角按传感器画幅加 letterbox 遮幅,使主视口、镜头监视器与视频导出的取景逐像素同源,虚化按传感器画幅归一化。镜头监视器新增全屏查看(Esc 关闭),支持拖拽瞄准与单击对焦并显示景深诊断读数,像素比上限改为 2 以消除机位视角的放大模糊。监视器与主视口统一按自动切机位口径解析当前机位。相机面板新增所选物体距离显示与一键对焦。
2026-09-20 18:10:03 +08:00

591 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, vi } from "vitest";
import { clearViewportCaptureHandler, setViewportCaptureHandler } from "../io/captureBridge";
import { createInitialDirectorState, useDirectorStore } from "../store/directorStore";
import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, getCameraLookRotationFromTarget } from "../schema/cameraGeometry";
import { CameraPanel } from "./CameraPanel";
function seedCameraCapture() {
useDirectorStore.setState((state) => ({
...state,
project: {
...state.project,
cameras: state.project.cameras.map((camera) =>
camera.id === "cam_1"
? {
...camera,
lastCaptureUrl: "data:image/png;base64,camera-preview",
captures: [
{
id: "cam_1-capture-01",
index: 1,
name: "机位01-截图01",
dataUrl: "data:image/png;base64,camera-preview",
},
],
}
: camera
),
},
}));
}
function seedGroupedCameraCaptures() {
const baseState = useDirectorStore.getState();
const firstCamera = baseState.project.cameras[0];
expect(firstCamera).toBeTruthy();
useDirectorStore.setState({
...baseState,
project: {
...baseState.project,
cameras: [
{
...firstCamera!,
captures: [
{
id: "cam_1-capture-01",
index: 1,
name: "机位01-截图01",
dataUrl: "data:image/png;base64,camera-1-a",
},
{
id: "cam_1-capture-02",
index: 2,
name: "机位01-截图02",
dataUrl: "data:image/png;base64,camera-1-b",
},
],
lastCaptureUrl: "data:image/png;base64,camera-1-b",
},
{
...firstCamera!,
id: "cam_2",
name: "机位02",
captures: [
{
id: "cam_2-capture-01",
index: 1,
name: "机位02-截图01",
dataUrl: "data:image/png;base64,camera-2-a",
},
],
lastCaptureUrl: "data:image/png;base64,camera-2-a",
},
],
},
});
}
beforeEach(() => {
useDirectorStore.setState({
...useDirectorStore.getState(),
...createInitialDirectorState(),
selectedObjectId: "cam_object_1",
});
});
afterEach(() => {
clearViewportCaptureHandler();
vi.restoreAllMocks();
});
it("renders the approved camera panel fields", () => {
render(<CameraPanel />);
expect(screen.getByText("摄像机")).toBeInTheDocument();
expect(screen.getByLabelText("机位名称")).toBeInTheDocument();
expect(screen.getByLabelText("切换机位")).toBeInTheDocument();
expect(screen.getByLabelText("机位位置 X")).toBeInTheDocument();
expect(screen.getByLabelText("注视目标模式")).toBeInTheDocument();
expect(screen.getByLabelText("机位水平角")).toBeInTheDocument();
expect(screen.getByLabelText("机位俯仰角")).toBeInTheDocument();
expect(screen.getByLabelText("机位横滚角")).toBeInTheDocument();
expect(screen.getByLabelText("机位 FOV")).toBeInTheDocument();
});
it("uses the provided right inspector layout for camera properties", () => {
const { container } = render(<CameraPanel />);
expect(screen.getByLabelText("摄像机右侧属性面板")).toHaveClass("right-inspector");
expect(container.querySelector(".right-inspector-tabs")).toBeInTheDocument();
expect(container.querySelector(".right-inspector-content")).toBeInTheDocument();
expect(screen.queryByLabelText("机位预览卡片")).not.toBeInTheDocument();
expect(screen.queryByText("FOV 50°")).not.toBeInTheDocument();
expect(container.querySelector("select.inspector-select-input")).not.toBeInTheDocument();
expect(screen.getByLabelText("切换机位")).toHaveClass("inspector-dropdown-trigger");
expect(screen.getByLabelText("注视目标模式")).toHaveClass("inspector-dropdown-trigger");
const positionY = screen.getByLabelText("机位位置 Y").closest(".inspector-axis-input");
const fovField = screen.getByLabelText("机位 FOV").closest(".inspector-range-row");
expect(positionY).toBeInTheDocument();
expect(within(positionY as HTMLElement).getByText("Y")).toHaveClass("inspector-axis-prefix");
expect(fovField).toBeInTheDocument();
});
it("shows the selected subject distance and focuses the camera onto it in one click", async () => {
const user = userEvent.setup();
// 选中可对焦物体(角色)后,面板直接给出「机位 → 物体」的实测距离
useDirectorStore.setState({
...useDirectorStore.getState(),
selectedObjectId: "char_default_a",
});
render(<CameraPanel />);
// 默认机位 [0, 1.55, 5.4] → 角色焦点 [0, 0.99, 0] ≈ 5.4m
expect(screen.getByLabelText("所选物体距离")).toHaveTextContent("所选物体 5.4 m");
await user.click(screen.getByRole("button", { name: "对焦到所选物体" }));
const camera = useDirectorStore.getState().project.cameras[0];
expect(camera.focusDistance).toBeCloseTo(5.43, 1);
});
it("keeps camera panel tab labels in fixed slots while switching tabs", async () => {
const user = userEvent.setup();
const { container } = render(<CameraPanel />);
const tabList = container.querySelector(".right-inspector-tabs");
const propertyTab = screen.getByRole("button", { name: "属性" });
const capturesTab = screen.getByRole("button", { name: "摄像机截图" });
expect(tabList).toHaveClass("right-inspector-tabs");
expect(propertyTab).toHaveClass("right-inspector-tab-button");
expect(capturesTab).toHaveClass("right-inspector-tab-button");
expect(propertyTab).toHaveAttribute("aria-pressed", "true");
await user.click(capturesTab);
expect(propertyTab).toHaveClass("right-inspector-tab-button");
expect(capturesTab).toHaveClass("right-inspector-tab-button");
expect(capturesTab).toHaveAttribute("aria-pressed", "true");
});
it("updates the selected camera name and fov", async () => {
const user = userEvent.setup();
render(<CameraPanel />);
await user.clear(screen.getByLabelText("机位名称"));
await user.type(screen.getByLabelText("机位名称"), "近景机位");
await user.clear(screen.getByLabelText("机位 FOV"));
await user.type(screen.getByLabelText("机位 FOV"), "65");
const camera = useDirectorStore.getState().project.cameras[0];
expect(camera.name).toBe("近景机位");
expect(camera.fov).toBe(65);
});
it("uses the custom dropdown menu to switch camera shots", async () => {
const user = userEvent.setup();
useDirectorStore.getState().addCameraShot();
render(<CameraPanel />);
await user.click(screen.getByLabelText("切换机位"));
const menu = screen.getByRole("listbox", { name: "切换机位" });
expect(menu).toHaveClass("inspector-dropdown-menu");
await user.click(within(menu).getByRole("option", { name: "机位01" }));
expect(useDirectorStore.getState().project.activeCameraId).toBe("cam_1");
expect(screen.queryByRole("listbox", { name: "切换机位" })).not.toBeInTheDocument();
});
it("renders target mode as the custom dropdown menu", async () => {
const user = userEvent.setup();
render(<CameraPanel />);
await user.click(screen.getByLabelText("注视目标模式"));
const menu = screen.getByRole("listbox", { name: "注视目标模式" });
const manualOption = within(menu).getByRole("option", { name: "手动角度" });
expect(menu).toHaveClass("inspector-dropdown-menu");
expect(manualOption).toHaveClass("is-selected");
expect(manualOption).toHaveAttribute("aria-selected", "true");
});
it("lists visible viewport models as camera focus targets and centers on the selected model", async () => {
const user = userEvent.setup();
useDirectorStore.getState().addPresetCharacter("female");
useDirectorStore.getState().addGeometryPrimitive("box");
render(<CameraPanel />);
await user.click(screen.getByLabelText("注视目标模式"));
const menu = screen.getByRole("listbox", { name: "注视目标模式" });
const modelNames = within(menu).getAllByRole("option").map((option) => option.textContent);
expect(modelNames).toEqual(["手动角度", "角色01", "角色02", "立方体"]);
expect(within(menu).queryByRole("option", { name: "机位01" })).not.toBeInTheDocument();
await user.click(within(menu).getByRole("option", { name: "角色02" }));
const state = useDirectorStore.getState();
const focusedObject = state.project.objects.find((item) => item.name === "角色02");
const camera = state.project.cameras.find((item) => item.id === state.project.activeCameraId);
expect(focusedObject).toBeTruthy();
expect(camera?.targetMode).toBe("object");
expect(camera?.targetObjectId).toBe(focusedObject?.id);
// 锁定物体 = 把「机位 → 物体」这条视线换算成注视朝向角
expect(camera?.lookRotation).toEqual(
getCameraLookRotationFromTarget(camera!.transform.position, [-1.25, 0.99, 0])
);
expect(screen.getByLabelText("注视目标模式")).toHaveTextContent("角色02");
// rotation = [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)]
expect(screen.getByLabelText("机位水平角")).toHaveValue(camera!.lookRotation[1]);
expect(screen.getByLabelText("机位俯仰角")).toHaveValue(camera!.lookRotation[0]);
expect(screen.getByLabelText("机位横滚角")).toHaveValue(camera!.lookRotation[2]);
});
it("resets the camera position and the look angles from their own buttons", async () => {
const user = userEvent.setup();
render(<CameraPanel />);
const readCamera = () => useDirectorStore.getState().project.cameras.find((item) => item.id === "cam_1");
const seededCamera = readCamera();
expect(seededCamera).toBeTruthy();
const positionReset = within(screen.getByRole("group", { name: "位置" })).getByRole("button", { name: "重置位置" });
expect(positionReset).toHaveClass("inspector-axis-reset-button");
expect(positionReset.querySelector("svg")).toBeInTheDocument();
// 三个角度各自一行,行首带重置按钮
["水平角", "俯仰角", "横滚角"].forEach((label) => {
const button = within(screen.getByRole("group", { name: label })).getByRole("button", { name: `重置${label}` });
expect(button).toHaveClass("inspector-axis-reset-button");
expect(button.querySelector("svg")).toBeInTheDocument();
});
useDirectorStore.getState().updateCamera("cam_1", {
transform: { ...seededCamera!.transform, position: [7, 3, 7] },
lookRotation: [12, 34, 56],
});
await user.click(screen.getByRole("button", { name: "重置位置" }));
// 机位回到默认机位位置(不是归零:相机压在世界原点会让视线方向退化)
expect(readCamera()?.transform.position).toEqual([0, 1.55, 5.4]);
// 只重置位置,注视朝向保持原样
expect(readCamera()?.lookRotation).toEqual([12, 34, 56]);
// 单独重置「俯仰角」→ 归零(水平),不带动水平角 / 横滚角
await user.click(within(screen.getByRole("group", { name: "俯仰角" })).getByRole("button", { name: "重置俯仰角" }));
expect(readCamera()?.lookRotation).toEqual([0, 34, 56]);
await user.click(screen.getByRole("button", { name: "重置注视角度" }));
// 俯仰角重置为水平 0°,水平角 / 横滚角回到默认机位
expect(readCamera()?.lookRotation).toEqual([
0,
DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation[1],
DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.lookRotation[2],
]);
expect(readCamera()?.targetMode).toBe("manual");
});
it("updates camera position and look angles across all axes", async () => {
const user = userEvent.setup();
render(<CameraPanel />);
await user.clear(screen.getByLabelText("机位位置 Y"));
await user.type(screen.getByLabelText("机位位置 Y"), "3.4");
await user.clear(screen.getByLabelText("机位位置 Z"));
await user.type(screen.getByLabelText("机位位置 Z"), "7.5");
await user.clear(screen.getByLabelText("机位水平角"));
await user.type(screen.getByLabelText("机位水平角"), "210");
await user.clear(screen.getByLabelText("机位俯仰角"));
await user.type(screen.getByLabelText("机位俯仰角"), "12");
const camera = useDirectorStore.getState().project.cameras[0];
expect(camera.transform.position).toEqual([0, 3.4, 7.5]);
// lookRotation = [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)]
expect(camera.lookRotation).toEqual([12, 210, 0]);
});
it("captures the current camera preview from the properties tab and shows it in the screenshots overview", async () => {
const user = userEvent.setup();
setViewportCaptureHandler(async () => [
{
label: "当前机位",
dataUrl: "data:image/png;base64,camera-preview",
meta: {
mode: "camera",
cameraId: "cam_1",
fov: 50,
position: [0, 2.2, 9],
target: [0, 1.2, 0],
},
},
]);
render(<CameraPanel />);
await user.click(screen.getByRole("button", { name: "当前机位截图" }));
await user.click(screen.getByRole("button", { name: "摄像机截图" }));
expect(useDirectorStore.getState().project.cameras[0]?.lastCaptureUrl).toBe("data:image/png;base64,camera-preview");
expect(useDirectorStore.getState().project.cameras[0]?.captures).toEqual([
{
id: "cam_1-capture-01",
index: 1,
name: "机位01-截图01",
dataUrl: "data:image/png;base64,camera-preview",
},
]);
expect(await screen.findByAltText("机位01-截图01 缩略图")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "机位01截图" })).toBeInTheDocument();
expect(screen.getByText("机位01-截图01")).toBeInTheDocument();
});
it("keeps the camera capture section visible at the bottom of the properties tab", () => {
render(<CameraPanel />);
expect(screen.getByRole("heading", { name: "相机截图" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "当前机位截图" })).toHaveClass("camera-capture-current-button");
expect(screen.getByTestId("camera-current-capture-icon")).toBeInTheDocument();
});
it("renders the thumbnail actions in a bottom bar and opens the project-style viewer toolbar", async () => {
const user = userEvent.setup();
seedCameraCapture();
render(<CameraPanel />);
expect(screen.getByRole("group", { name: "机位01-截图01 缩略图操作" })).toHaveClass("camera-capture-actions");
await user.click(screen.getByLabelText("查看截图 机位01-截图01"));
const viewer = screen.getByRole("dialog", { name: "相机截图查看器" });
const toolbar = within(viewer).getByRole("toolbar", { name: "相机截图查看器工具栏" });
expect(viewer).toBeInTheDocument();
expect(screen.getByAltText("机位01-截图01 查看大图")).toBeInTheDocument();
expect(within(toolbar).getByRole("button", { name: "放大图片" })).toBeInTheDocument();
expect(within(toolbar).getByRole("button", { name: "缩小图片" })).toBeInTheDocument();
expect(within(toolbar).getByRole("button", { name: "下载图片" })).toBeInTheDocument();
expect(within(toolbar).getByRole("button", { name: "关闭相机截图查看器" })).toBeInTheDocument();
});
it("sends a single camera capture to the host canvas when the thumbnail action is clicked", async () => {
const user = userEvent.setup();
const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation(() => undefined);
seedCameraCapture();
render(<CameraPanel />);
await user.click(screen.getByRole("button", { name: "发送到画布 机位01-截图01" }));
expect(postMessage).toHaveBeenCalledWith(
{
type: "storyai:director-desk-captures-sent",
payload: {
captures: [
{
dataUrl: "data:image/png;base64,camera-preview",
fileName: "机位01-截图01.png",
},
],
},
},
window.location.origin
);
});
it("shows all camera screenshots grouped by camera in the screenshots tab", async () => {
const user = userEvent.setup();
seedGroupedCameraCaptures();
render(<CameraPanel />);
await user.click(screen.getByRole("button", { name: "摄像机截图" }));
const firstGroup = screen.getByRole("region", { name: "机位01截图" });
const secondGroup = screen.getByRole("region", { name: "机位02截图" });
expect(screen.queryByRole("heading", { name: "相机截图" })).not.toBeInTheDocument();
expect(within(firstGroup).getByRole("heading", { name: "机位01截图" })).toBeInTheDocument();
expect(within(firstGroup).getByText("机位01-截图01")).toBeInTheDocument();
expect(within(firstGroup).getByText("机位01-截图02")).toBeInTheDocument();
expect(within(secondGroup).getByRole("heading", { name: "机位02截图" })).toBeInTheDocument();
expect(within(secondGroup).getByText("机位02-截图01")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "清空全部" })).toHaveClass("camera-capture-clear-all");
expect(screen.getByRole("button", { name: "发送到画布" })).toHaveClass(
"camera-capture-send-all",
"viewport-toolbar-crowd-confirm"
);
expect(screen.getByRole("button", { name: "发送到画布" })).not.toHaveClass("is-hover-state");
expect(screen.getByTestId("camera-capture-clear-icon")).toBeInTheDocument();
expect(screen.getByTestId("camera-capture-send-icon")).toBeInTheDocument();
const panel = screen.getByLabelText("摄像机右侧属性面板");
const content = panel.querySelector(".right-inspector-content");
const footer = panel.querySelector(".camera-capture-overview-footer");
expect(footer).toBeInTheDocument();
expect(content).toBeInTheDocument();
expect(content).not.toContainElement(footer as HTMLElement);
});
it("sends all visible camera screenshots to the host canvas from the overview footer", async () => {
const user = userEvent.setup();
const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation(() => undefined);
seedGroupedCameraCaptures();
render(<CameraPanel />);
await user.click(screen.getByRole("button", { name: "摄像机截图" }));
await user.click(screen.getByRole("button", { name: "发送到画布" }));
expect(postMessage).toHaveBeenCalledWith(
{
type: "storyai:director-desk-captures-sent",
payload: {
captures: [
{
dataUrl: "data:image/png;base64,camera-1-a",
fileName: "机位01-截图01.png",
},
{
dataUrl: "data:image/png;base64,camera-1-b",
fileName: "机位01-截图02.png",
},
{
dataUrl: "data:image/png;base64,camera-2-a",
fileName: "机位02-截图01.png",
},
],
},
},
window.location.origin
);
});
it("clears every camera screenshot from the screenshots tab and shows the empty state", async () => {
const user = userEvent.setup();
seedGroupedCameraCaptures();
render(<CameraPanel />);
await user.click(screen.getByRole("button", { name: "摄像机截图" }));
await user.click(screen.getByRole("button", { name: "清空全部" }));
expect(screen.queryByText("机位01-截图01")).not.toBeInTheDocument();
const emptyState = screen.getByRole("status", { name: "暂无摄像机截图" });
expect(emptyState).toHaveClass("camera-capture-empty", "object-search-empty-state");
expect(screen.getByTestId("camera-capture-empty-icon")).toHaveClass("object-search-empty-icon");
expect(screen.getByTestId("camera-capture-empty-icon").querySelector(".lucide-images")).toBeInTheDocument();
expect(screen.getByTestId("camera-capture-empty-icon").querySelector(".lucide-search")).not.toBeInTheDocument();
expect(screen.getByText("暂无摄像机截图")).toBeInTheDocument();
expect(useDirectorStore.getState().project.cameras.map((camera) => camera.captures ?? [])).toEqual([[], []]);
expect(useDirectorStore.getState().project.cameras.map((camera) => camera.lastCaptureUrl ?? null)).toEqual([
null,
null,
]);
});
it("renders the capture viewer as a portal outside the side panel", async () => {
const user = userEvent.setup();
seedCameraCapture();
const { container } = render(<CameraPanel />);
await user.click(screen.getByLabelText("查看截图 机位01-截图01"));
const viewer = screen.getByRole("dialog", { name: "相机截图查看器" });
// 侧栏卡片带 backdrop-filter,会成为 fixed 定位元素的包含块:查看器必须挂在 body 上,
// 否则 position: fixed; inset: 0 会相对侧栏解析并被裁剪,看起来就不是弹出层了
expect(viewer.parentElement).toBe(document.body);
expect(container.contains(viewer)).toBe(false);
expect(viewer).toHaveAttribute("aria-modal", "true");
});
it("opens the capture viewer when the capture card itself is clicked", async () => {
const user = userEvent.setup();
seedCameraCapture();
render(<CameraPanel />);
// 点名称(缩略图外的区域)也应该弹出大图
await user.click(screen.getByText("机位01-截图01"));
expect(screen.getByRole("dialog", { name: "相机截图查看器" })).toBeInTheDocument();
expect(screen.getByAltText("机位01-截图01 查看大图")).toBeInTheDocument();
});
it("closes the capture viewer when clicking outside the image", async () => {
const user = userEvent.setup();
seedCameraCapture();
render(<CameraPanel />);
await user.click(screen.getByLabelText("查看截图 机位01-截图01"));
const previewImage = screen.getByAltText("机位01-截图01 查看大图");
// 查看器通过 portal 挂在 body 上,所以要直接从 document 查
const viewerStage = document.querySelector(".camera-capture-viewer-stage");
expect(viewerStage).toBeInTheDocument();
await user.click(previewImage);
expect(screen.getByRole("dialog", { name: "相机截图查看器" })).toBeInTheDocument();
await user.click(viewerStage as HTMLElement);
expect(screen.queryByRole("dialog", { name: "相机截图查看器" })).not.toBeInTheDocument();
});
it("zooms the capture preview through the viewer toolbar controls with the canvas image preview step", async () => {
const user = userEvent.setup();
seedCameraCapture();
render(<CameraPanel />);
await user.click(screen.getByLabelText("查看截图 机位01-截图01"));
await user.click(screen.getByRole("button", { name: "放大图片" }));
expect(screen.getByAltText("机位01-截图01 查看大图")).toHaveStyle({
transform: "translate(0px, 0px) scale(1.25)",
});
});
it("supports wheel zooming and dragging like the canvas image preview", async () => {
const user = userEvent.setup();
seedCameraCapture();
render(<CameraPanel />);
await user.click(screen.getByLabelText("查看截图 机位01-截图01"));
const previewImage = screen.getByAltText("机位01-截图01 查看大图");
fireEvent.wheel(previewImage, { deltaY: -100 });
expect(previewImage).toHaveStyle({ transform: "translate(0px, 0px) scale(1.25)" });
fireEvent.mouseDown(previewImage, { clientX: 100, clientY: 100 });
fireEvent.mouseMove(window, { clientX: 124, clientY: 132 });
fireEvent.mouseUp(window);
expect(previewImage).toHaveStyle({ transform: "translate(24px, 32px) scale(1.25)" });
});
it("deletes a camera capture from the screenshot grid", async () => {
const user = userEvent.setup();
seedCameraCapture();
render(<CameraPanel />);
await user.click(screen.getByLabelText("删除截图 机位01-截图01"));
expect(screen.queryByText("机位01-截图01")).not.toBeInTheDocument();
expect(useDirectorStore.getState().project.cameras[0]?.captures).toEqual([]);
expect(useDirectorStore.getState().project.cameras[0]?.lastCaptureUrl).toBeNull();
});