fix: 优化空格播放和截图角色标签对不上

This commit is contained in:
2026-09-17 19:39:24 +08:00
parent 8d67f17a84
commit e66e3374bf
19 changed files with 633 additions and 58 deletions

View File

@@ -380,7 +380,9 @@ it("shares the scene transform with the ground plane so the grid never becomes a
render(<App />);
const grid = screen.getByTestId("viewport-grid");
const sceneGroup = grid.closest("group");
// 网格被包在「导出时隐藏」的 group 里,再外层才是与地面共享变换的场景 group
const gridWrapper = grid.closest("group");
const sceneGroup = gridWrapper?.parentElement?.closest("group");
// 网格与地面同在场景组内:共享场景平移/旋转/缩放,不会各自留在不同坐标空间
expect(sceneGroup).not.toBeNull();

View File

@@ -26,6 +26,7 @@ import { SceneRoot } from "./SceneRoot";
import { AXIS_COLORS } from "./sceneAxes";
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround";
import { getSceneGroupTransform } from "./sceneTransform";
import { HIDE_FROM_VIEWPORT_CAPTURE_KEY, withViewportCaptureHelpersHidden } from "./viewportCapture";
import { PlaybackFrameDriver } from "../runtime/PlaybackFrameDriver";
import { ActiveCameraPlaybackSync } from "../runtime/ActiveCameraPlaybackSync";
import { PlaybackTransformSync } from "./PlaybackTransformSync";
@@ -69,7 +70,6 @@ const RIGHT_PANEL_WIDTH = 300;
const GIZMO_EDGE_PADDING = 20;
/** 悬浮卡片比面板自身宽度多占的留白(左/右各 --floating-inset,再加同宽间距) */
const FLOATING_PANEL_GAP = 32;
const HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture";
const CAPTURE_LABEL_FONT_SIZE = 12;
const CAPTURE_LABEL_HORIZONTAL_PADDING = 10;
const CAPTURE_LABEL_VERTICAL_PADDING = 6;
@@ -245,7 +245,7 @@ function getCharacterCaptureLabelY(item: DirectorObject) {
: getGroundedLabelY(item.bodyType);
}
function getViewportCaptureLabels() {
export function getViewportCaptureLabels(captureScene?: Object3D) {
const {
project: { objects, scene },
} = useDirectorStore.getState();
@@ -253,14 +253,15 @@ function getViewportCaptureLabels() {
if (!scene.showLabels) return [];
const sceneMatrix = createSceneMatrix(scene);
// 角色在场景里的真实世界坐标可能已被播放头 / 人物轨迹挪动,先刷新一遍矩阵,保证读到的位置是「当前这帧」的
if (captureScene && typeof captureScene.updateMatrixWorld === "function") captureScene.updateMatrixWorld(true);
return objects
.filter((item) => item.kind === "character" && item.visible)
.map((item): ViewportCaptureLabel => {
const objectMatrix = createTransformMatrix(item.transform);
const worldPosition = new Vector3(0, getCharacterCaptureLabelY(item), 0)
.applyMatrix4(objectMatrix)
.applyMatrix4(sceneMatrix);
// 优先用角色在场景里的真实世界坐标(播放头移动 / 人物轨迹会让它偏离静态 transform),
// 这样截图上标签才贴着角色头部,而不是落在它摆放时的 0 点坐标。
const worldPosition = findCharacterWorldPosition(captureScene, item) ?? computeCharacterLabelWorldPosition(item, sceneMatrix);
return {
text: item.name,
@@ -269,6 +270,28 @@ function getViewportCaptureLabels() {
});
}
function computeCharacterLabelWorldPosition(item: DirectorObject, sceneMatrix: Matrix4): Vector3 {
const objectMatrix = createTransformMatrix(item.transform);
return new Vector3(0, getCharacterCaptureLabelY(item), 0)
.applyMatrix4(objectMatrix)
.applyMatrix4(sceneMatrix);
}
/** 去场景里找这个角色的根 group(userData.directorObjectId === item.id),取它当前的世界坐标 + 标签高度 */
function findCharacterWorldPosition(captureScene: Object3D | undefined, item: DirectorObject): Vector3 | null {
if (!captureScene) return null;
let found: Object3D | undefined;
captureScene.traverse((object) => {
if (!found && object.userData?.directorObjectId === item.id) found = object;
});
if (!found) return null;
const position = found.getWorldPosition(new Vector3());
position.y += getCharacterCaptureLabelY(item);
return position;
}
function getCssRgbVariable(name: string, fallback: string) {
if (typeof window === "undefined") return fallback;
@@ -434,25 +457,6 @@ function captureViewportCanvas(
return cropCanvas.toDataURL("image/png");
}
function withViewportCaptureHelpersHidden(scene: Object3D, render: () => void) {
const hiddenObjects: Array<{ object: Object3D; visible: boolean }> = [];
scene.traverse((object) => {
if (object.userData?.[HIDE_FROM_VIEWPORT_CAPTURE_KEY]) {
hiddenObjects.push({ object, visible: object.visible });
object.visible = false;
}
});
try {
render();
} finally {
hiddenObjects.forEach(({ object, visible }) => {
object.visible = visible;
});
}
}
function CanvasCaptureBridge({
activeCamera,
bottomPadding,
@@ -507,7 +511,7 @@ function CanvasCaptureBridge({
label,
dataUrl: captureViewportCanvas(gl.domElement, viewportAspectRatio, bottomPadding, safeAreaInsets, {
camera: workingCamera,
labels: getViewportCaptureLabels(),
labels: getViewportCaptureLabels(scene),
}),
meta: buildScreenshotMeta({
mode: viewMode,
@@ -844,15 +848,19 @@ export function DirectorCanvas() {
// 网格和地面一样属于「场景内容」:共用同一套场景变换 + 同一尺寸,
// 变换不同会让两者错位,尺寸不同会让边缘不重合 —— 两种都会看成"两层地面"。
<group {...getSceneGroupTransform(sceneSettings)}>
<Grid
args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]}
cellThickness={0}
fadeDistance={80}
position={[0, sceneSettings.groundHeight + GROUND_GRID_ELEVATION, 0]}
sectionColor="#2A4065"
side={GROUND_SURFACE_SIDE}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
/>
{/* 导出时(截图 / 视频)是否把网格藏起来由 captureShowGrid 决定:
默认 false → 打上 hideFromViewportCapture,导出前统一藏起;勾选后不标记,网格留在画面里。
用一层 group 承载标记,避免依赖 <Grid> 自身是否转发 userData。 */}
<group userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: !sceneSettings.captureShowGrid }}>
<Grid
args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]}
cellThickness={0}
fadeDistance={80}
position={[0, sceneSettings.groundHeight + GROUND_GRID_ELEVATION, 0]}
sectionColor="#2A4065"
side={GROUND_SURFACE_SIDE}
/>
</group>
</group>
) : null}
{viewMode === "director" ? (
@@ -916,11 +924,6 @@ export function DirectorCanvas() {
</Suspense>
</Canvas>
</div>
{trajectoryPlacementObjectId ? (
<div className="viewport-placement-hint" role="status" aria-label="轨迹打点提示">
双击画布放置轨迹点 · Esc 或再次点击「添加轨迹」退出
</div>
) : null}
<ViewportAspectOverlay
bottomPadding={aspectOverlayBottomPadding}
onToggleRuleOfThirds={setViewportRuleOfThirdsEnabled}
@@ -938,6 +941,12 @@ export function DirectorCanvas() {
重置视角
</button>
</div>
{/* 轨迹打点提示:独立浮层,显示在快捷工具栏「上方」,不嵌进工具栏里 */}
{trajectoryPlacementObjectId ? (
<div className="viewport-placement-hint" role="status" aria-label="轨迹打点提示">
双击画布放置轨迹点 · Esc 或再次点击「添加轨迹」退出
</div>
) : null}
<ViewportToolbar getViewportCameraSnapshot={getViewportCameraSnapshot} toolbarContainerRef={toolbarRef} />
</div>
);

View File

@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { Box3, DoubleSide, Vector3 } from "three";
import { afterEach, beforeEach, vi } from "vitest";
import { VIEWPORT_CAMERA_VISUAL_SCALE } from "../schema/cameraGeometry";
@@ -274,6 +275,16 @@ it("uses the requested dark ground surface color", () => {
expect(groundMaterial?.outerHTML.toLowerCase()).toContain("polygonoffsetunits=\"1\"");
});
it("paints the ground with the ground color from the scene settings", () => {
useDirectorStore.getState().updateScene({ groundColor: "#204060" });
const { container } = render(<SceneRoot />);
// 地面颜色和天空颜色一样是工程设置:面板上改了颜色,视口里的地面材质就得跟着变
expect(container.querySelector('meshbasicmaterial[color="#204060"]')).toBeInTheDocument();
expect(container.querySelector('meshbasicmaterial[color="#303640"]')).not.toBeInTheDocument();
});
it("keeps the ground visible when the view drops below the ground plane", () => {
const { container } = render(<SceneRoot />);
const groundMaterial = container.querySelector('meshbasicmaterial[color="#303640"]');
@@ -680,6 +691,21 @@ it("renders solid trajectory spheres and a smooth curve through the waypoints",
expect(container.querySelectorAll('mesh[name^="trajectory-point-"]')).toHaveLength(2);
});
it("keeps the trajectory marker material writing depth so an opaque ground cannot hide it", () => {
// 这条约束落在材质参数上(jsdom 里 <mesh> / <meshBasicMaterial> 只是普通 DOM 节点,
// depthWrite 这种 unknown 布尔属性不会落到 DOM 上,跑不出来),所以按源码契约断言。
const source = readFileSync("src/editor/canvas/SceneRoot.tsx", "utf8");
const markerMaterialLine = source
.split("\n")
.find((line) => line.includes("<meshBasicMaterial color={selected ? TRAJECTORY_SELECTED_COLOR : color}"));
expect(markerMaterialLine).toBeDefined();
// 地面是 transparent 材质:即使透明度为 1 也走透明通道、排在所有不透明物体之后渲染。
// 小球一旦不写深度(depthWrite={false}),地面会整片盖住它 ——
// 表现就是「地面透明度为 1 时看不到轨迹点」。
expect(markerMaterialLine).not.toContain("depthWrite");
});
it("puts a walking character at its trajectory sample for the current playhead", () => {
useDirectorStore.getState().addTrajectoryPoint("char_default_a", [8, 0, 0]);
// 单点轨迹:0s 在角色起始位置,时间轴末尾(5s)到达该轨迹点

View File

@@ -41,6 +41,7 @@ import { getEffectiveGroundOpacity } from "./panoramaMath";
import { getGroundAxisLines } from "./sceneAxes";
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround";
import { getSceneGroupTransform } from "./sceneTransform";
import { HIDE_FROM_VIEWPORT_CAPTURE_KEY } from "./viewportCapture";
import { getCrowdAnchorTransform } from "../store/directorStore";
export { getEffectiveGroundOpacity, getPanoramaRotationRadians } from "./panoramaMath";
@@ -55,7 +56,6 @@ const VIEWPORT_CAMERA_LINE_OPACITY = 0.92;
const VIEWPORT_CAMERA_HIT_PADDING = 0.06;
const VIEWPORT_CAMERA_FORWARD = new Vector3(0, 0, 1);
const VIEWPORT_CAMERA_WORLD_UP = new Vector3(0, 1, 0);
const HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture";
const VIEWPORT_CAMERA_BODY_CENTER: CameraWirePoint = [0, 0, -0.52 * VIEWPORT_CAMERA_VISUAL_SCALE];
const VIEWPORT_CAMERA_BODY_SIZE: CameraWirePoint = [
0.4 * VIEWPORT_CAMERA_VISUAL_SCALE,
@@ -967,14 +967,17 @@ function TrajectoryPath({
<circleGeometry args={[0.3, 24]} />
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
</mesh>
{/* 实心小球:选中用高亮色,否则用角色色 */}
{/* 实心小球:选中用高亮色,否则用角色色。
这里必须写深度(用材质默认值 true,别改成 false):地面是 transparent 材质,
即使透明度为 1 也走透明通道、排在所有不透明物体之后渲染;
小球一旦不写深度,地面就会整片盖住它 —— 表现就是「地面不透明时看不到轨迹点」。 */}
<mesh
name={`trajectory-point-${point.id}`}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
onClick={interactive ? (event) => selectPoint(point.id, event) : undefined}
>
<sphereGeometry args={[0.1, 18, 14]} />
<meshBasicMaterial color={selected ? TRAJECTORY_SELECTED_COLOR : color} depthWrite={false} />
<meshBasicMaterial color={selected ? TRAJECTORY_SELECTED_COLOR : color} />
</mesh>
</group>
);
@@ -1073,7 +1076,7 @@ export function SceneRoot() {
<mesh position={[0, scene.groundHeight, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]} />
<meshBasicMaterial
color="#303640"
color={scene.groundColor}
opacity={getEffectiveGroundOpacity(scene.groundOpacity, Boolean(panoramaAsset))}
polygonOffset
polygonOffsetFactor={1}

View File

@@ -1,5 +1,5 @@
import { expect, it } from "vitest";
import type { SceneSettings } from "../schema/directorProject";
import { DEFAULT_GROUND_COLOR, type SceneSettings } from "../schema/directorProject";
import { getSceneGroupTransform } from "./sceneTransform";
function createScene(patch: Partial<SceneSettings> = {}): SceneSettings {
@@ -11,8 +11,10 @@ function createScene(patch: Partial<SceneSettings> = {}): SceneSettings {
panoramaYaw: 0,
panoramaRadius: 60,
showLabels: true,
captureShowGrid: false,
snapToGrid: false,
showGround: true,
groundColor: DEFAULT_GROUND_COLOR,
groundOpacity: 0.4,
groundHeight: 0,
...patch,

View File

@@ -0,0 +1,56 @@
import { expect, it } from "vitest";
import { Group, Object3D } from "three";
import {
HIDE_FROM_VIEWPORT_CAPTURE_KEY,
hideViewportCaptureHelpers,
withViewportCaptureHelpersHidden,
} from "./viewportCapture";
function makeFlaggedObject() {
const object = new Object3D();
object.userData[HIDE_FROM_VIEWPORT_CAPTURE_KEY] = true;
return object;
}
it("hides every flagged helper and restores the original visibility afterwards", () => {
const root = new Group();
const flagged = makeFlaggedObject();
const nestedFlagged = makeFlaggedObject();
const kept = new Object3D();
const nestedKept = new Object3D();
flagged.add(nestedFlagged, nestedKept);
root.add(flagged, kept);
// 先藏起来一个,验证 restore 还原的是「进入前的可见性」而不是无脑设 true
flagged.visible = false;
const restore = hideViewportCaptureHelpers(root);
expect(flagged.visible).toBe(false); // 本来就隐藏,hider 不该改它
expect(nestedFlagged.visible).toBe(false);
expect(kept.visible).toBe(true);
expect(nestedKept.visible).toBe(true);
restore();
expect(flagged.visible).toBe(false); // 还原回 hider 进入前的状态
expect(nestedFlagged.visible).toBe(true);
expect(kept.visible).toBe(true);
expect(nestedKept.visible).toBe(true);
});
it("hides helpers only for the render call and always restores, even when render throws", () => {
const root = new Group();
const flagged = makeFlaggedObject();
root.add(flagged);
const seenDuringRender: boolean[] = [];
expect(() =>
withViewportCaptureHelpersHidden(root, () => {
seenDuringRender.push(flagged.visible);
throw new Error("render blew up");
})
).toThrow("render blew up");
expect(seenDuringRender).toEqual([false]);
expect(flagged.visible).toBe(true);
});

View File

@@ -0,0 +1,44 @@
import type { Object3D } from "three";
/**
* 截图 / 视频导出时要从画面里去掉的编辑器辅助元素。
*
* 约定:凡是「只在编辑器里给用户看」的东西 —— 视口网格、地面 X/Z 坐标轴、人物轨迹与路点、
* 机位线框与拾取盒、拖拽 gizmo(那三条红绿蓝轴)、名字标签……都在 userData 里打上这个标记。
*
* 截图与视频导出必须在渲染前统一把它们隐藏、渲完立刻恢复,并且**共用这一份实现**:
* 两边各写一套时,很容易出现「截图干净、导出的视频里却带着 gizmo 红绿蓝轴」这类不一致。
*/
export const HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture";
/**
* 隐藏场景里所有带标记的辅助对象,返回「恢复原样」的函数。
*
* 适合需要跨多帧保持隐藏的场合(如实时录屏兜底路径,整段录制期间都不能露出来)。
*/
export function hideViewportCaptureHelpers(scene: Object3D): () => void {
const hiddenObjects: Array<{ object: Object3D; visible: boolean }> = [];
scene.traverse((object) => {
if (object.userData?.[HIDE_FROM_VIEWPORT_CAPTURE_KEY]) {
hiddenObjects.push({ object, visible: object.visible });
object.visible = false;
}
});
return () => {
hiddenObjects.forEach(({ object, visible }) => {
object.visible = visible;
});
};
}
/** 藏起辅助元素后渲染一帧;无论渲染成功还是抛错,都把可见性恢复原状。 */
export function withViewportCaptureHelpersHidden(scene: Object3D, render: () => void) {
const restore = hideViewportCaptureHelpers(scene);
try {
render();
} finally {
restore();
}
}

View File

@@ -0,0 +1,68 @@
import { Group, Scene } from "three";
import { beforeEach, describe, expect, it } from "vitest";
import type { DirectorObject } from "../schema/directorProject";
import { createDefaultDirectorProject, useDirectorStore } from "../store/directorStore";
import { getViewportCaptureLabels } from "./DirectorCanvas";
describe("getViewportCaptureLabels", () => {
beforeEach(() => {
useDirectorStore.getState().replaceProject(createDefaultDirectorProject());
});
it("places each label at the character's real scene position, not its static transform (0,0,0)", () => {
const project = createDefaultDirectorProject();
const characters = project.objects.filter((o): o is DirectorObject => o.kind === "character");
expect(characters.length).toBeGreaterThan(0);
// 把所有角色静态 transform 摆到原点,但在场景里把它们挪到别处 ——
// 截图标签必须贴着「场景里真实的位置」,而不是落回 0 点坐标。
const scene = new Scene();
const nameToPosition = new Map<string, [number, number, number]>();
characters.forEach((item, index) => {
const group = new Group();
group.userData.directorObjectId = item.id;
const position: [number, number, number] = [10 + index, 0.5, 20 + index];
group.position.set(position[0], position[1], position[2]);
scene.add(group);
nameToPosition.set(item.name, position);
item.transform.position = [0, 0, 0];
});
useDirectorStore.getState().replaceProject(project);
const labels = getViewportCaptureLabels(scene);
expect(labels.length).toBe(characters.length);
for (const label of labels) {
const expected = nameToPosition.get(label.text);
expect(expected).toBeTruthy();
// 用 x / z 验证:若误用静态 transform(原点),这里会是 0
expect(label.worldPosition.x).toBeCloseTo(expected![0], 5);
expect(label.worldPosition.z).toBeCloseTo(expected![2], 5);
// y 在角色头部高度(基础 y + 标签高度),应高于场景里摆放的 y
expect(label.worldPosition.y).toBeGreaterThan(expected![1]);
}
});
it("returns nothing when scene.showLabels is off", () => {
const project = createDefaultDirectorProject();
project.scene.showLabels = false;
useDirectorStore.getState().replaceProject(project);
expect(getViewportCaptureLabels(new Scene())).toEqual([]);
});
});
describe("captureShowGrid setting", () => {
beforeEach(() => {
useDirectorStore.getState().replaceProject(createDefaultDirectorProject());
});
it("defaults to false (grid hidden during capture) and toggles", () => {
expect(useDirectorStore.getState().project.scene.captureShowGrid).toBe(false);
useDirectorStore.getState().updateScene({ captureShowGrid: true });
expect(useDirectorStore.getState().project.scene.captureShowGrid).toBe(true);
});
});

View File

@@ -1,7 +1,26 @@
import { beforeEach, expect, it } from "vitest";
import { beforeEach, expect, it, vi } from "vitest";
import { Object3D, PerspectiveCamera, Scene, Vector2 } from "three";
import { usePlaybackStore } from "../runtime/playbackStore";
import { useDirectorStore } from "../store/directorStore";
import { exportVideo } from "./videoExport";
vi.mock("mp4-muxer", () => ({
Muxer: class {
target = { buffer: new ArrayBuffer(8) };
addVideoChunk() {}
finalize() {}
},
ArrayBufferTarget: class {},
}));
const contextRef = vi.hoisted(() => ({
current: null as null | { gl: any; scene: Scene; camera: PerspectiveCamera },
}));
vi.mock("./videoExportBridge", () => ({
getVideoExportContext: () => contextRef.current,
}));
beforeEach(() => {
usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, loop: false, editingObjectId: null });
});
@@ -44,3 +63,68 @@ it("keeps the playhead where the export found it", async () => {
expect(usePlaybackStore.getState().playhead).toBe(2.5);
});
it("hides editor helpers (gizmo / grid / axes) while rendering exported frames, then restores them", async () => {
// 模拟场景里有一个「导出时隐藏」的辅助物体(也就是选中对象拖拽 gizmo 的那三条红绿蓝轴)
const scene = new Scene();
const helper = new Object3D();
helper.userData.hideFromViewportCapture = true;
const solidObject = new Object3D();
scene.add(helper, solidObject);
const visibilityDuringRender: boolean[] = [];
const gl = {
domElement: document.createElement("canvas"),
render: vi.fn(() => {
// 在渲染这一帧的瞬间记录辅助物体的可见性
visibilityDuringRender.push(helper.visible);
}),
getSize: (size: Vector2) => size.set(300, 150),
getPixelRatio: () => 1,
setPixelRatio: () => {},
setSize: () => {},
};
const camera = new PerspectiveCamera();
contextRef.current = { gl, scene, camera };
// 短时长,缩小导出帧数,加速测试
useDirectorStore.getState().setTimelineDuration(0.1);
// jsdom 没有 WebCodecs,这里把逐帧导出需要的几个全局桩好
const VideoEncoderStub = class {
static isConfigSupported = () => Promise.resolve({ supported: true });
output = () => {};
error = () => {};
encodeQueueSize = 0;
configure() {}
encode() {}
flush() {
return Promise.resolve();
}
close() {}
};
const VideoFrameStub = class {
close() {}
};
vi.stubGlobal("VideoEncoder", VideoEncoderStub);
vi.stubGlobal("VideoFrame", VideoFrameStub);
vi.stubGlobal("URL", {
...URL,
createObjectURL: () => "blob:fake",
revokeObjectURL: () => {},
});
try {
await exportVideo("director");
} finally {
vi.unstubAllGlobals();
}
expect(gl.render).toHaveBeenCalled();
// 每一帧渲染时,辅助物体都必须藏起来,否则导出的视频里会带着 gizmo 红绿蓝轴
expect(visibilityDuringRender.length).toBeGreaterThan(0);
expect(visibilityDuringRender.every((v) => v === false)).toBe(true);
// 导出结束后原样恢复
expect(helper.visible).toBe(true);
expect(solidObject.visible).toBe(true);
});

View File

@@ -4,6 +4,7 @@ import { Vector2 } from "three";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
import { getVideoExportContext } from "./videoExportBridge";
import { hideViewportCaptureHelpers, withViewportCaptureHelpersHidden } from "../canvas/viewportCapture";
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms";
import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio";
import { normalizeTimelineFps } from "../schema/timeline";
@@ -190,7 +191,9 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
editingObjectId: usePlaybackStore.getState().editingObjectId,
});
gl.render(scene, frameCamera);
// 和截图同一套:渲染这一帧前先把编辑器辅助元素(gizmo 红绿蓝轴 / 网格 / 坐标轴 / 轨迹)藏起来,
// 否则导出的视频里会带着选中的 gizmo 三条轴。渲完 withViewportCaptureHelpersHidden 会原样恢复。
withViewportCaptureHelpersHidden(scene, () => gl.render(scene, frameCamera));
const frame = new VideoFrame(canvas, {
timestamp: frameIndex * frameDurationUs,
@@ -250,12 +253,20 @@ async function exportVideoWithMediaRecorder(view: VideoExportView): Promise<void
const directorStore = useDirectorStore.getState();
const duration = directorStore.project.timeline.duration;
// 实时录屏兜底路径录的是「实时画面」,整段录制期间都要藏住编辑器辅助元素
//(gizmo 红绿蓝轴 / 网格 / 坐标轴等)—— 不能像逐帧路径那样只在某一帧渲染时隐藏。
const captureBridge = getVideoExportContext();
const restoreCaptureHelpers = captureBridge ? hideViewportCaptureHelpers(captureBridge.scene) : () => {};
// 切视角 + 复位指针(时间轴本身就是单次播放,到末尾会自己停住)
directorStore.setViewMode(view);
usePlaybackStore.getState().setPlayhead(0);
const stopped = new Promise<void>((resolve) => {
recorder.onstop = () => resolve();
recorder.onstop = () => {
restoreCaptureHelpers();
resolve();
};
});
recorder.start();

View File

@@ -49,7 +49,7 @@ it("lays scene switches out in one row and only toggles from the checkbox", asyn
const checkbox = screen.getByLabelText("角色标签");
expect(switchRow).toBeInTheDocument();
expect(switchRow?.querySelectorAll(".inspector-toggle-row")).toHaveLength(3);
expect(switchRow?.querySelectorAll(".inspector-toggle-row")).toHaveLength(4);
expect(checkbox).toBeChecked();
await user.click(labelText);
@@ -79,6 +79,8 @@ it("updates scene transform, panorama, and ground controls", async () => {
await user.type(screen.getByLabelText("全景球半径"), "90");
await user.click(screen.getByLabelText("角色标签"));
await user.click(screen.getByLabelText("网格吸附"));
await user.clear(screen.getByLabelText("地面颜色 HEX"));
await user.type(screen.getByLabelText("地面颜色 HEX"), "#204060");
await user.clear(screen.getByLabelText("地面透明度"));
await user.type(screen.getByLabelText("地面透明度"), "0.65");
await user.clear(screen.getByLabelText("地面高度"));
@@ -93,6 +95,7 @@ it("updates scene transform, panorama, and ground controls", async () => {
expect(scene.panoramaRadius).toBe(90);
expect(scene.showLabels).toBe(false);
expect(scene.snapToGrid).toBe(true);
expect(scene.groundColor).toBe("#204060");
expect(scene.groundOpacity).toBe(0.65);
expect(scene.groundHeight).toBe(1.2);
});
@@ -277,15 +280,17 @@ it("updates panorama yaw and ground height from both sliders and numeric inputs"
expect(screen.getByLabelText("地面高度")).toHaveValue(-1.5);
});
it("hides ground opacity and height controls when ground is disabled", async () => {
it("hides ground color, opacity, and height controls when ground is disabled", async () => {
const user = userEvent.setup();
render(<ScenePanel />);
expect(screen.getByLabelText("地面颜色")).toBeInTheDocument();
expect(screen.getByLabelText("地面透明度")).toBeInTheDocument();
expect(screen.getByLabelText("地面高度")).toBeInTheDocument();
await user.click(screen.getByLabelText("地面"));
expect(screen.queryByLabelText("地面颜色")).not.toBeInTheDocument();
expect(screen.queryByLabelText("地面透明度")).not.toBeInTheDocument();
expect(screen.queryByLabelText("地面高度")).not.toBeInTheDocument();
});

View File

@@ -302,6 +302,15 @@ export function ScenePanel() {
/>
<span>角色标签</span>
</div>
<div className="inspector-toggle-row">
<input
aria-label="截图/录制时显示网格"
checked={scene.captureShowGrid}
type="checkbox"
onChange={(event) => updateScene({ captureShowGrid: event.target.checked })}
/>
<span>截图/录制时显示网格</span>
</div>
<div className="inspector-toggle-row">
<input
aria-label="网格吸附"
@@ -324,6 +333,15 @@ export function ScenePanel() {
</InspectorSection>
{scene.showGround ? (
<InspectorSection title="地面">
{/* 和「天空颜色」同款控件:取色块 + HEX 输入框,两个入口都写同一个字段 */}
<InspectorColorField
label="颜色"
colorAriaLabel="地面颜色"
hexAriaLabel="地面颜色 HEX"
value={scene.groundColor}
onColorChange={(value) => updateScene({ groundColor: value })}
onHexChange={(value) => updateScene({ groundColor: value })}
/>
<InspectorRangeNumberField
label="透明度"
rangeAriaLabel="地面透明度滑杆"

View File

@@ -94,6 +94,56 @@ it("renders play button and the frame/time readout with the total duration on it
expect(container.querySelector(".timeline-duration-caption")?.textContent).toBe("总时长");
});
it("toggles playback with Space while the timeline panel has focus", () => {
const { container } = render(<TimelinePanel />);
const panel = container.querySelector("section.timeline-panel") as HTMLElement;
// 焦点在时间轴上(例如刻度尺 / 面板本身)→ 空格:暂停 → 播放 → 暂停
fireEvent.keyDown(panel, { key: " " });
expect(usePlaybackStore.getState().isPlaying).toBe(true);
fireEvent.keyDown(panel, { key: " " });
expect(usePlaybackStore.getState().isPlaying).toBe(false);
});
it("toggles playback with Space when focus is on the toolbar (timeline-toolbar), not only on the ruler", () => {
const { container } = render(<TimelinePanel />);
const toolbar = container.querySelector(".timeline-toolbar") as HTMLElement;
expect(toolbar).toBeTruthy();
// 点工具栏空白处后焦点落在工具栏区 → 空格切换播放 / 暂停
fireEvent.keyDown(toolbar, { key: " " });
expect(usePlaybackStore.getState().isPlaying).toBe(true);
fireEvent.keyDown(toolbar, { key: " " });
expect(usePlaybackStore.getState().isPlaying).toBe(false);
});
it("toggles playback with Space when focus is on a track (the grid body), not only on the ruler", () => {
const { container } = render(<TimelinePanel />);
const gridBody = container.querySelector(".timeline-grid-body") as HTMLElement;
expect(gridBody).toBeTruthy();
// 点轨道空白处后焦点落在轨道区 → 空格切换播放 / 暂停
fireEvent.keyDown(gridBody, { key: " " });
expect(usePlaybackStore.getState().isPlaying).toBe(true);
fireEvent.keyDown(gridBody, { key: " " });
expect(usePlaybackStore.getState().isPlaying).toBe(false);
});
it("does not double-toggle when Space is pressed on a native button inside the timeline", () => {
// 播放按钮自身会在松开空格时触发点击(由浏览器处理),面板层应跳过、不要重复切换
usePlaybackStore.setState({ isPlaying: true });
const { container } = render(<TimelinePanel />);
const playButton = screen.getByRole("button", { name: "暂停" });
fireEvent.keyDown(playButton, { key: " " });
// 焦点在 button 上,面板层的空格处理让位给按钮,播放状态不应被这次按键改变
expect(usePlaybackStore.getState().isPlaying).toBe(true);
});
it("shows the playhead as a frame number next to the time", () => {
usePlaybackStore.getState().setPlayhead(1.5);
@@ -810,6 +860,78 @@ it("keeps the camera clip inside the timeline when dragged past the end", () =>
expect(times[1]).toBeCloseTo(5, 5);
});
it("selects the trajectory clip when it is grabbed, and clears it on an empty area click", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
useDirectorStore.getState().setTrajectoryPointTimes(character.id, [1.5, 3]);
const { container } = render(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const bar = container.querySelector<HTMLElement>(".timeline-trajectory-lane .timeline-trajectory-bar")!;
expect(bar).not.toHaveClass("is-selected");
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
expect(bar).toHaveClass("is-selected");
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 100 });
// 点轨道空白处=取消选中(片段自己会 stopPropagation,所以刚才那一下不会被清掉)
fireEvent.pointerDown(container.querySelector<HTMLElement>(".timeline-grid-body")!, {
button: 0,
pointerId: 2,
clientX: 5,
});
expect(container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")).not.toHaveClass("is-selected");
});
it("selects the clip when one of its own keyframes is grabbed", () => {
const character = getDefaultCharacter();
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
useDirectorStore.getState().setTrajectoryPointTimes(character.id, [1.5, 3]);
const { container } = render(<TimelinePanel />);
// 按住关键帧(真实点击的第一步)就把整段片段标成选中:帧和条是同一个编辑对象
const keyframe = screen.getByRole("button", { name: "轨迹关键帧 1.5s" });
fireEvent.pointerDown(keyframe, { button: 0, pointerId: 1, clientX: 150 });
fireEvent.pointerUp(keyframe, { pointerId: 1, clientX: 150 });
expect(container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")).toHaveClass("is-selected");
});
it("selects the camera clip both when its body is grabbed and when its keyframe is grabbed", () => {
const camera = useDirectorStore.getState().project.cameras[0];
addCameraKeyframeAt(camera.id, 1);
addCameraKeyframeAt(camera.id, 3);
const { container } = render(<TimelinePanel />);
stubTrackRowLayout({}, 500);
const bar = container.querySelector<HTMLElement>(".timeline-clip-bar-camera")!;
expect(bar).not.toHaveClass("is-selected");
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
expect(bar).toHaveClass("is-selected");
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 100 });
// 点空白处取消选中后,再按住关键帧同样能把片段带亮
fireEvent.pointerDown(container.querySelector<HTMLElement>(".timeline-grid-body")!, {
button: 0,
pointerId: 2,
clientX: 5,
});
expect(container.querySelector(".timeline-clip-bar-camera")).not.toHaveClass("is-selected");
const keyframe = screen.getByRole("button", { name: "运镜关键帧 1.0s" });
fireEvent.pointerDown(keyframe, { button: 0, pointerId: 3, clientX: 100 });
fireEvent.pointerUp(keyframe, { pointerId: 3, clientX: 100 });
expect(container.querySelector(".timeline-clip-bar-camera")).toHaveClass("is-selected");
});
it("jumps to the first and last frame from the transport controls", () => {
usePlaybackStore.getState().setPlayhead(3);
render(<TimelinePanel />);

View File

@@ -70,6 +70,11 @@ type TimelineTimeDragOwner =
| { kind: "trajectory"; objectId: string; pointId: string }
| { kind: "camera"; cameraId: string };
/** 时间轴片段(人物轨迹条 / 运镜片段条)的稳定标识 */
function getTimelineClipKey(kind: "trajectory" | "camera", id: string) {
return `${kind}:${id}`;
}
function formatTime(t: number) {
return `${t.toFixed(1)}s`;
}
@@ -335,6 +340,8 @@ export function TimelinePanel() {
const editableDuration = project.timeline.duration ?? duration;
const [selectedKeyframe, setSelectedKeyframe] = useState<SelectedKeyframe | null>(null);
/** 选中的片段(轨迹条 / 运镜条):点条本体或条上的关键帧都会选中它 */
const [selectedClip, setSelectedClip] = useState<string | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
// 设置弹窗:fps / loop 先放草稿里改,点「完成」才写回工程(不点则原样丢弃)
@@ -758,6 +765,29 @@ export function TimelinePanel() {
};
}
/**
* 点轨道空白处 = 取消片段选中。
* 片段 / 关键帧自己的手势里会 stopPropagation,正常情况冒泡不到这里;
* 但手势可能因为尺寸未就绪等原因提前返回(那时还没走到 stopPropagation),
* 所以这里再按事件来源兜一层,避免把刚选中的片段立刻清掉。
*/
function handleTimelineGridPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
if (event.target instanceof Element && event.target.closest(".timeline-trajectory-bar, .timeline-keyframe")) {
return;
}
setSelectedClip(null);
}
/**
* 片段是否处于选中态。只有一个来源:`selectedClip` ——
* 任何一次片段手势(拖条本体 / 拖把手 / 拖条上的关键帧)都会把这条片段标成选中,
* 点轨道空白处取消。不跟关键帧 / 轨迹点选中做联动,是为了让「点空白处取消选中」始终有效。
*/
function isClipSelected(kind: "trajectory" | "camera", id: string) {
return selectedClip === getTimelineClipKey(kind, id);
}
/** 拖整段时长(条右端把手 / 键盘微调):末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定 */
function scaleClipDuration(
owner: TimelineTimeDragOwner,
@@ -837,6 +867,9 @@ export function TimelinePanel() {
if (event.button !== 0) return;
if (isTrackDragBlockedTarget(event.target)) return;
// 任何一次片段手势(拖条本体 / 拖把手 / 拖关键帧)都先把这段片段标成选中
setSelectedClip(getTimelineClipKey(owner.kind, owner.kind === "trajectory" ? owner.objectId : owner.cameraId));
const lane = event.currentTarget.closest(".timeline-track-lane");
const laneWidth = lane instanceof HTMLElement ? lane.getBoundingClientRect().width : 0;
if (laneWidth <= 0 || duration <= 0) return;
@@ -1077,6 +1110,18 @@ export function TimelinePanel() {
action();
}
// 焦点在时间轴上时,空格键切换播放 / 暂停(正在播放则暂停,暂停或停止则播放)。
// 焦点落在原生可激活控件(按钮 / 输入框等)上时交给控件自身处理,避免与它们的空格行为重复触发。
function handlePanelKeyDown(event: ReactKeyboardEvent<HTMLElement>) {
if (event.key !== " " && event.key !== "Spacebar") return;
const target = event.target as HTMLElement | null;
if (target && target.closest("button, input, textarea, select, [contenteditable='true']")) {
return;
}
event.preventDefault();
togglePlaying();
}
const addKeyframeDisabled = selectionIsManualCamera ? !canAddCameraKeyframe : !selectedObject;
const addKeyframeTitle = selectionIsManualCamera
? canAddCameraKeyframe
@@ -1091,8 +1136,11 @@ export function TimelinePanel() {
className={`timeline-panel${draggingTrackKey ? " is-track-dragging" : ""}`}
aria-label="时间轴"
ref={panelRef}
onKeyDown={handlePanelKeyDown}
>
<div className="timeline-toolbar">
{/* tabIndex 让工具栏区可聚焦:点工具栏空白处后焦点落进面板,
这样「焦点在 timeline-toolbar 时空格切换播放/暂停」才生效(否则焦点在面板外,空格够不到面板级键盘处理) */}
<div className="timeline-toolbar" tabIndex={0}>
<div className="timeline-toolbar-group">
{/* 读数 = 当前帧号 / 当前时间;总时长单独放在右边的时长输入框里 */}
<span
@@ -1335,7 +1383,10 @@ export function TimelinePanel() {
</div>
</div>
<div className="timeline-grid-body">
{/* 点轨道空白处(不含片段与关键帧)=取消片段选中 */}
{/* tabIndex 让整块轨道区可聚焦:点轨道空白处后焦点会落进面板,
这样「焦点在轨道上时空格切换播放/暂停」才能生效(否则焦点在面板外,空格够不到面板级的键盘处理) */}
<div className="timeline-grid-body" tabIndex={0} onPointerDown={handleTimelineGridPointerDown}>
{timeline.tracks.map((track) => {
const obj = project.objects.find((o) => o.id === track.objectId);
const KindIcon = (obj ? OBJECT_KIND_ICONS[obj.kind] : undefined) ?? Box;
@@ -1442,7 +1493,9 @@ export function TimelinePanel() {
拖右端把手整体缩放这段运镜的时长;只有一个关键帧时没有「运镜过程」,不画条。 */}
{cameraTimes.length >= 2 && duration > 0 ? (
<div
className="timeline-trajectory-bar timeline-clip-bar-camera"
className={`timeline-trajectory-bar timeline-clip-bar-camera${
isClipSelected("camera", track.cameraId) ? " is-selected" : ""
}`}
style={{ left: `${clipLeftPercent}%`, width: `${clipWidthPercent}%` }}
title={`运镜片段 ${formatTime(clipStart)} → ${formatTime(
clipEnd
@@ -1595,7 +1648,9 @@ export function TimelinePanel() {
{trajectoryTimes.length > 0 && duration > 0 ? (
<>
<div
className="timeline-trajectory-bar"
className={`timeline-trajectory-bar${
isClipSelected("trajectory", character.id) ? " is-selected" : ""
}`}
style={{ left: `${trajectoryLeftPercent}%`, width: `${trajectoryWidthPercent}%` }}
title={`轨迹片段 ${formatTime(trajectoryStart)} → ${formatTime(
trajectoryEndTime

View File

@@ -42,16 +42,27 @@ export const DEFAULT_DIRECTOR_TRANSFORM: DirectorTransform = {
scale: [1, 1, 1],
};
/**
* 地面默认颜色:深灰。
* 默认场景与「旧工程迁移」共用这一份,不要在两处各写一个色值。
*/
export const DEFAULT_GROUND_COLOR = "#303640";
export interface SceneSettings {
scale: number;
position: [number, number, number];
rotation: [number, number, number];
/** 天空(背景)颜色:没接全景图时铺满整个视口 */
backgroundColor: string;
panoramaYaw: number;
panoramaRadius: number;
showLabels: boolean;
/** 截图 / 视频导出时是否把视口网格一起带进画面(默认 false:导出时藏起网格,更干净) */
captureShowGrid: boolean;
snapToGrid: boolean;
showGround: boolean;
/** 地面颜色:和天空颜色一样属于工程设置,可在场景面板里改 */
groundColor: string;
groundOpacity: number;
groundHeight: number;
}

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, vi } from "vitest";
import { createDefaultDirectorProject, createInitialDirectorState, useDirectorStore } from "./directorStore";
import { selectRightPanelKind } from "./directorSelectors";
import { getCameraRigPositionFromViewSnapshot } from "../schema/cameraGeometry";
import { DEFAULT_GROUND_COLOR } from "../schema/directorProject";
function createMemoryStorage(): Storage {
const storage = new Map<string, string>();
@@ -875,6 +876,8 @@ it("replaceProject migrates a legacy v1 project into a v2 project with a timelin
fps: 30,
loop: false,
});
// 旧场景里没有 groundColor:迁移补默认地面色,否则地面会落到材质默认白
expect(project.scene.groundColor).toBe(DEFAULT_GROUND_COLOR);
});
it("addKeyframe inserts a keyframe on the object's track and returns its id", () => {

View File

@@ -1,6 +1,11 @@
import { create } from "zustand";
import { MANNEQUIN_POSE_PRESETS } from "../presets/mannequinPosePresets";
import { GEOMETRY_PRIMITIVE_OPTIONS, normalizeTrajectoryRotation, normalizeTransformRotation } from "../schema/directorProject";
import {
DEFAULT_GROUND_COLOR,
GEOMETRY_PRIMITIVE_OPTIONS,
normalizeTrajectoryRotation,
normalizeTransformRotation,
} from "../schema/directorProject";
import type {
DirectorAssetRef,
DirectorAssetSource,
@@ -245,8 +250,10 @@ const DEFAULT_SCENE: SceneSettings = {
panoramaYaw: 0,
panoramaRadius: 60,
showLabels: true,
captureShowGrid: false,
snapToGrid: false,
showGround: true,
groundColor: DEFAULT_GROUND_COLOR,
groundOpacity: 0.4,
groundHeight: 0,
};
@@ -477,8 +484,31 @@ function withTimeline(project: DirectorProject): DirectorProject {
};
}
/**
* 旧工程里还没有「地面颜色」这一项(当时是写死的深灰):补上默认值,
* 否则加载后地面会落到材质默认色(白),和天空色一起看非常刺眼。
*/
function withSceneDefaults(project: DirectorProject): DirectorProject {
const scene = project.scene;
if (!scene) return project;
const nextScene = { ...scene };
if (!(typeof scene.groundColor === "string" && scene.groundColor)) {
nextScene.groundColor = DEFAULT_GROUND_COLOR;
}
// 旧工程没有「导出时是否带网格」这一项:补默认值(false = 导出时藏起网格)
if (typeof nextScene.captureShowGrid !== "boolean") {
nextScene.captureShowGrid = false;
}
if (nextScene.groundColor === scene.groundColor && nextScene.captureShowGrid === scene.captureShowGrid) {
return project;
}
return { ...project, scene: nextScene };
}
function migrateDirectorProject(project: DirectorProject): DirectorProject {
const withTimelineProject = withTimeline(project);
const withTimelineProject = withSceneDefaults(withTimeline(project));
return {
...withTimelineProject,
objects: withTimelineProject.objects.map((object) => {

View File

@@ -4334,6 +4334,18 @@ body.is-timeline-dragging * {
0 0 0 1px rgb(var(--panel-rgb) / 0.9),
0 0 0 4px rgb(18 184 134 / 0.25);
}
/* 片段选中态:底色 + 边框 + 外描边一起加重(轨迹条走强调色,运镜条走它自己的绿)。
必须排在「未选中」规则之后:同优先级下靠源序决定,选中态才盖得住。 */
.timeline-trajectory-bar.is-selected {
border-color: rgb(var(--accent-rgb) / 0.85);
background: rgb(var(--accent-rgb) / 0.2);
box-shadow: 0 0 0 1px rgb(var(--accent-rgb) / 0.45);
}
.timeline-clip-bar-camera.is-selected {
border-color: rgb(18 184 134 / 0.85);
background: rgb(18 184 134 / 0.2);
box-shadow: 0 0 0 1px rgb(18 184 134 / 0.45);
}
.timeline-playhead-layer {
position: absolute;
top: 0;
@@ -4624,7 +4636,10 @@ body.is-timeline-dragging * {
.viewport-placement-hint {
position: absolute;
left: 50%;
bottom: calc(40px + var(--viewport-toolbar-height, 46px) + 12px);
/* 紧贴在快捷工具栏「上方」独立显示,与工具栏留一道明显空隙,避免看起来像嵌在工具栏里。
同时加上 --timeline-panel-height:录制弹出时间轴时工具条会整体抬上去,
提示也要跟着一起上移,否则会被留在原地、盖在时间轴上。 */
bottom: calc(40px + var(--timeline-panel-height, 0px) + var(--viewport-toolbar-height, 46px) + 22px);
z-index: 35;
padding: 6px 14px;
border: 1px solid rgb(var(--border-rgb) / 0.42);

View File

@@ -283,6 +283,17 @@ it("shows icon names as a floating label on viewport toolbar hover and focus", (
expect(css).toMatch(/\.viewport-toolbar-button:hover\s*\.viewport-toolbar-label,\s*[\r\n]+\s*\.viewport-toolbar-button:focus-visible\s*\.viewport-toolbar-label\s*\{[\s\S]*?opacity:\s*1;[\s\S]*?visibility:\s*visible;/);
});
it("declares the selected clip style after the default clip style", () => {
const css = readFileSync("src/styles/index.css", "utf8");
const baseIndex = css.indexOf(".timeline-clip-bar-camera {");
const selectedIndex = css.indexOf(".timeline-clip-bar-camera.is-selected");
expect(baseIndex).toBeGreaterThan(-1);
expect(selectedIndex).toBeGreaterThan(-1);
// 两条规则同优先级,靠源序决定胜负:选中态必须写在默认态之后才盖得住
expect(selectedIndex).toBeGreaterThan(baseIndex);
});
it("keeps the color swatch background from leaking at its rounded corners", () => {
const css = readFileSync("src/styles/index.css", "utf8");
const swatchRule = css.match(/\.inspector-color-swatch\s*\{(?<body>[\s\S]*?)\}/)?.groups?.body ?? "";