feat: 优化时间轴
This commit is contained in:
@@ -159,30 +159,39 @@ vi.mock("@react-three/drei", async () => {
|
||||
Grid: ({
|
||||
args,
|
||||
cellColor,
|
||||
cellSize,
|
||||
cellThickness,
|
||||
fadeDistance,
|
||||
infiniteGrid,
|
||||
position,
|
||||
sectionColor,
|
||||
sectionSize,
|
||||
sectionThickness,
|
||||
side,
|
||||
}: {
|
||||
args?: [number, number];
|
||||
cellColor?: string;
|
||||
cellSize?: number;
|
||||
cellThickness?: number;
|
||||
fadeDistance?: number;
|
||||
infiniteGrid?: boolean;
|
||||
position?: [number, number, number];
|
||||
sectionColor?: string;
|
||||
sectionSize?: number;
|
||||
sectionThickness?: number;
|
||||
side?: number;
|
||||
}) => (
|
||||
<div
|
||||
data-args={JSON.stringify(args)}
|
||||
data-cell-color={cellColor}
|
||||
data-cell-size={String(cellSize)}
|
||||
data-cell-thickness={String(cellThickness)}
|
||||
data-fade-distance={String(fadeDistance)}
|
||||
data-infinite-grid={String(Boolean(infiniteGrid))}
|
||||
data-position={JSON.stringify(position)}
|
||||
data-section-color={sectionColor}
|
||||
data-section-size={String(sectionSize)}
|
||||
data-section-thickness={String(sectionThickness)}
|
||||
data-side={String(side)}
|
||||
data-testid="viewport-grid"
|
||||
/>
|
||||
@@ -292,13 +301,19 @@ it("does not render a full-viewport transform drag layer over the 3D viewport",
|
||||
expect(screen.queryByRole("application", { name: "3D视口缩放拖拽层" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only the dark major viewport grid lines on the same footprint as the ground", () => {
|
||||
it("renders a fine viewport grid with thicker, darker major lines every 5 cells on the same footprint as the ground", () => {
|
||||
render(<App />);
|
||||
|
||||
const grid = screen.getByTestId("viewport-grid");
|
||||
expect(grid).toHaveAttribute("data-cell-thickness", "0");
|
||||
expect(grid).toHaveAttribute("data-position", "[0,0.002,0]");
|
||||
expect(grid).toHaveAttribute("data-section-color", "#2A4065");
|
||||
expect(grid).toHaveAttribute("data-cell-size", "1");
|
||||
expect(grid).toHaveAttribute("data-section-size", "5");
|
||||
// 每 5 格一条更粗的分割线
|
||||
expect(grid).toHaveAttribute("data-section-thickness", "1.4");
|
||||
// 细分割线比主分割线更细
|
||||
expect(grid).toHaveAttribute("data-cell-thickness", "0.5");
|
||||
// 主分割线颜色更浅(中性灰,突出 RGB 坐标轴)
|
||||
expect(grid).toHaveAttribute("data-section-color", "#6b7280");
|
||||
expect(grid).toHaveAttribute("data-fade-distance", "80");
|
||||
// 网格不能是"无限网格":默认尺寸(1×1 × (1+fadeDistance))比地面小得多,
|
||||
// 边缘不重合就会被看成另一层地面。这里必须和地面同尺寸。
|
||||
@@ -450,7 +465,7 @@ it("renders the native 3D viewport gizmo in an overlay canvas above the aspect m
|
||||
expect(screen.getByTestId("native-gizmo-helper")).toHaveAttribute("data-margin", "[0,0]");
|
||||
expect(screen.getByTestId("native-gizmo-viewport")).toHaveAttribute(
|
||||
"data-axis-colors",
|
||||
"[\"#E56C5B\",\"#6CDB7A\",\"#7AA7FF\"]"
|
||||
"[\"#FF4D4F\",\"#52C41A\",\"#1890FF\"]"
|
||||
);
|
||||
expect(screen.getByTestId("native-gizmo-viewport")).toHaveAttribute("data-disabled", "true");
|
||||
expect(screen.getByTestId("native-gizmo-viewport")).toHaveAttribute("data-scale", "25");
|
||||
|
||||
@@ -24,7 +24,7 @@ import { getGroundedLabelY } from "../runtime/mannequin/bodyTypes";
|
||||
import { getUE4GroundedLabelY } from "../runtime/ue4Mannequin/ue4MannequinRig";
|
||||
import { SceneRoot } from "./SceneRoot";
|
||||
import { AXIS_COLORS } from "./sceneAxes";
|
||||
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround";
|
||||
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE, GRID_CELL_SIZE, GRID_SECTION_CELLS } from "./sceneGround";
|
||||
import { getSceneGroupTransform } from "./sceneTransform";
|
||||
import { HIDE_FROM_VIEWPORT_CAPTURE_KEY, withViewportCaptureHelpersHidden } from "./viewportCapture";
|
||||
import { PlaybackFrameDriver } from "../runtime/PlaybackFrameDriver";
|
||||
@@ -74,6 +74,8 @@ const CAPTURE_LABEL_FONT_SIZE = 12;
|
||||
const CAPTURE_LABEL_HORIZONTAL_PADDING = 10;
|
||||
const CAPTURE_LABEL_VERTICAL_PADDING = 6;
|
||||
const CAPTURE_LABEL_BORDER_RADIUS = 999;
|
||||
/** 截图标签相对角色头顶的额外抬高量,让标签浮在头部上方、不遮住模型 */
|
||||
const CAPTURE_LABEL_Y_OFFSET = 0.4;
|
||||
const CAPTURE_LABEL_PANEL_RGB_FALLBACK = "26 26 26";
|
||||
const CAPTURE_LABEL_TEXT_RGB_FALLBACK = "255 255 255";
|
||||
const GIZMO_AXIS_HIT_TARGETS: Array<{
|
||||
@@ -240,9 +242,10 @@ function createSceneMatrix(scene: SceneSettings) {
|
||||
}
|
||||
|
||||
function getCharacterCaptureLabelY(item: DirectorObject) {
|
||||
return item.characterRig?.rigType === "ue4-mannequin"
|
||||
const base = item.characterRig?.rigType === "ue4-mannequin"
|
||||
? getUE4GroundedLabelY(item.bodyType)
|
||||
: getGroundedLabelY(item.bodyType);
|
||||
return base + CAPTURE_LABEL_Y_OFFSET;
|
||||
}
|
||||
|
||||
export function getViewportCaptureLabels(captureScene?: Object3D) {
|
||||
@@ -503,7 +506,7 @@ function CanvasCaptureBridge({
|
||||
const originalQuaternion = workingCamera.quaternion.clone();
|
||||
const originalFov = workingCamera.fov;
|
||||
|
||||
const snapshot = (label: string) => {
|
||||
const snapshot = (label: string, metaTarget?: [number, number, number]) => {
|
||||
withViewportCaptureHelpersHidden(scene, () => {
|
||||
gl.render(scene, workingCamera);
|
||||
});
|
||||
@@ -518,12 +521,35 @@ function CanvasCaptureBridge({
|
||||
cameraId: cameraId ?? (viewMode === "camera" ? activeCamera?.id ?? null : null),
|
||||
fov: workingCamera.fov,
|
||||
position: [workingCamera.position.x, workingCamera.position.y, workingCamera.position.z],
|
||||
target: [target.x, target.y, target.z],
|
||||
target: metaTarget ?? [target.x, target.y, target.z],
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
if (preset === "current") {
|
||||
// 机位面板触发的「当前机位」截图:渲染的是该机位自己的视角,
|
||||
// 而不是编辑器此刻所在的导演视角(导演视角通常更高、更远),
|
||||
// 否则用户在机位面板生成的预览会和切到相机视角看到的不一致。
|
||||
if (source === "camera-panel" && cameraId) {
|
||||
const captureCamera = useDirectorStore
|
||||
.getState()
|
||||
.project.cameras.find((item) => item.id === cameraId);
|
||||
if (captureCamera) {
|
||||
const view = getCameraViewSnapshotFromShot(captureCamera);
|
||||
const savedPosition = workingCamera.position.clone();
|
||||
const savedQuaternion = workingCamera.quaternion.clone();
|
||||
const savedFov = workingCamera.fov;
|
||||
applySnapshotToCamera(workingCamera, view);
|
||||
try {
|
||||
return [snapshot("当前机位", [...view.target])];
|
||||
} finally {
|
||||
workingCamera.position.copy(savedPosition);
|
||||
workingCamera.quaternion.copy(savedQuaternion);
|
||||
workingCamera.fov = savedFov;
|
||||
workingCamera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
}
|
||||
return [snapshot(source === "camera-panel" ? "当前机位" : "当前视角")];
|
||||
}
|
||||
|
||||
@@ -561,6 +587,32 @@ function CanvasCaptureBridge({
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 机位视角下屏蔽 Ctrl/Cmd + 滚轮,避免浏览器把整页放大缩小。
|
||||
* 相机模式下没有 OrbitControls 接管滚轮,浏览器默认的 ctrl+滚轮缩放页面会直接触发,
|
||||
* 监听 canvas 的 wheel 事件(非 passive)并在按下 ctrl/meta 时 preventDefault 即可拦截。 */
|
||||
function ViewportWheelGuard({ enabled }: { enabled: boolean }) {
|
||||
const { gl } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const canvas = gl.domElement;
|
||||
if (typeof canvas?.addEventListener !== "function") {
|
||||
return;
|
||||
}
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
canvas.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => canvas.removeEventListener("wheel", handleWheel);
|
||||
}, [enabled, gl]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 把渲染三件套注册给视频导出逻辑(逐帧离线渲染用),模式仿 CanvasCaptureBridge */
|
||||
function VideoExportBridge() {
|
||||
const { camera, gl, scene } = useThree();
|
||||
@@ -854,10 +906,14 @@ export function DirectorCanvas() {
|
||||
<group userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: !sceneSettings.captureShowGrid }}>
|
||||
<Grid
|
||||
args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]}
|
||||
cellThickness={0}
|
||||
cellSize={GRID_CELL_SIZE}
|
||||
sectionSize={GRID_CELL_SIZE * GRID_SECTION_CELLS}
|
||||
cellThickness={0.5}
|
||||
sectionThickness={1.4}
|
||||
cellColor="#33373f"
|
||||
sectionColor="#6b7280"
|
||||
fadeDistance={80}
|
||||
position={[0, sceneSettings.groundHeight + GROUND_GRID_ELEVATION, 0]}
|
||||
sectionColor="#2A4065"
|
||||
side={GROUND_SURFACE_SIDE}
|
||||
/>
|
||||
</group>
|
||||
@@ -915,6 +971,7 @@ export function DirectorCanvas() {
|
||||
viewportAspectRatio={viewportAspectRatio}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
<ViewportWheelGuard enabled={viewMode === "camera"} />
|
||||
<PlaybackFrameDriver />
|
||||
<VideoExportBridge />
|
||||
<PlaybackTransformSync />
|
||||
|
||||
@@ -128,7 +128,7 @@ it("uses the fullscreen button to collapse the side panels instead of entering b
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a new camera before storing viewport capsule screenshots from director view", async () => {
|
||||
it("stores viewport capsule screenshots on the active camera without creating a new one from director view", async () => {
|
||||
const user = userEvent.setup();
|
||||
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
const snapshot = {
|
||||
@@ -142,7 +142,7 @@ it("creates a new camera before storing viewport capsule screenshots from direct
|
||||
dataUrl: "data:image/png;base64,current-camera",
|
||||
meta: {
|
||||
mode: "camera" as const,
|
||||
cameraId: "cam_2",
|
||||
cameraId: "cam_1",
|
||||
fov: 64,
|
||||
position: [3, 2, 1] as [number, number, number],
|
||||
target: [0, 1, -2] as [number, number, number],
|
||||
@@ -156,30 +156,25 @@ it("creates a new camera before storing viewport capsule screenshots from direct
|
||||
await user.click(screen.getByRole("button", { name: "当前视角截图" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handler).toHaveBeenCalledWith({ preset: "current", source: "camera-panel", cameraId: "cam_2" });
|
||||
expect(handler).toHaveBeenCalledWith({ preset: "current", source: "camera-panel", cameraId: "cam_1" });
|
||||
});
|
||||
|
||||
const state = useDirectorStore.getState();
|
||||
const originalCamera = state.project.cameras[0];
|
||||
const newCamera = state.project.cameras[1];
|
||||
const activeCamera = state.project.cameras[0];
|
||||
|
||||
expect(anchorClick).not.toHaveBeenCalled();
|
||||
expect(state.viewMode).toBe("camera");
|
||||
expect(state.project.activeCameraId).toBe("cam_2");
|
||||
expect(state.selectedObjectId).toBe("cam_object_2");
|
||||
expect(originalCamera?.captures).toEqual([]);
|
||||
expect(newCamera?.fov).toBe(64);
|
||||
expect(newCamera?.transform.position).toEqual(getCameraRigPositionFromViewSnapshot(snapshot));
|
||||
expect(getCameraViewSnapshotFromShot(newCamera)).toEqual(snapshot);
|
||||
expect(newCamera?.captures).toEqual([
|
||||
expect(state.project.cameras).toHaveLength(1);
|
||||
expect(state.project.activeCameraId).toBe("cam_1");
|
||||
expect(activeCamera?.captures).toEqual([
|
||||
{
|
||||
id: "cam_2-capture-01",
|
||||
id: "cam_1-capture-01",
|
||||
index: 1,
|
||||
name: "机位02-截图01",
|
||||
name: "机位01-截图01",
|
||||
dataUrl: "data:image/png;base64,current-camera",
|
||||
},
|
||||
]);
|
||||
expect(newCamera?.lastCaptureUrl).toBe("data:image/png;base64,current-camera");
|
||||
expect(activeCamera?.lastCaptureUrl).toBe("data:image/png;base64,current-camera");
|
||||
});
|
||||
|
||||
it("stores viewport capsule screenshots in the current camera while already in camera view", async () => {
|
||||
|
||||
@@ -345,8 +345,8 @@ export function ViewportToolbar({
|
||||
|
||||
async function handleCapture(preset: "current" | "four" | "twelve") {
|
||||
try {
|
||||
const targetCameraId =
|
||||
viewMode === "director" ? addCameraShot(getViewportCameraSnapshot?.()) : activeCameraId;
|
||||
// 复用当前选中的机位承接截图,避免每次点截图都新增一个机位(四方位 / 十二方位尤其明显)
|
||||
const targetCameraId = activeCameraId ?? addCameraShot(getViewportCameraSnapshot?.());
|
||||
|
||||
setViewMode("camera");
|
||||
await waitForNextAnimationFrame();
|
||||
@@ -507,8 +507,8 @@ export function ViewportToolbar({
|
||||
},
|
||||
{ label: "模型库", icon: Boxes, onClick: toggleModelLibrary },
|
||||
{ label: "添加机位", icon: Video, onClick: addCameraFromViewport },
|
||||
{ label: "当前视角截图", icon: Camera, onClick: () => void handleCapture("current") },
|
||||
{ label: "选择画幅比例", icon: Ratio, onClick: toggleAspectRatioPanel },
|
||||
{ label: "当前视角截图", icon: Camera, onClick: () => void handleCapture("current") },
|
||||
{ label: "四方位截图", icon: Grid2X2, onClick: () => void handleCapture("four") },
|
||||
{ label: "十二方位截图", icon: Grid3X3, onClick: () => void handleCapture("twelve") },
|
||||
{ label: "全屏", icon: Expand, onClick: toggleViewportPanelsCollapsed },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AXIS_COLORS, getGroundAxisLines, GROUND_AXIS_LENGTH } from "./sceneAxes
|
||||
import { GROUND_PLANE_SIZE } from "./sceneGround";
|
||||
|
||||
it("keeps the axis color convention shared with the viewport gizmo", () => {
|
||||
expect(AXIS_COLORS).toEqual({ x: "#E56C5B", y: "#6CDB7A", z: "#7AA7FF" });
|
||||
expect(AXIS_COLORS).toEqual({ x: "#FF4D4F", y: "#52C41A", z: "#1890FF" });
|
||||
});
|
||||
|
||||
it("stretches each ground axis to both edges of the ground", () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { GROUND_PLANE_SIZE } from "./sceneGround";
|
||||
|
||||
/**
|
||||
* 轴向颜色约定:X 红 / Y 绿 / Z 蓝。
|
||||
* 轴向颜色约定:X 红 / Y 绿 / Z 蓝(标准 RGB)。
|
||||
* 视口右上角的坐标控件(全景球)和地面坐标轴共用这一份,避免两处颜色漂移。
|
||||
*/
|
||||
export const AXIS_COLORS = {
|
||||
x: "#E56C5B",
|
||||
y: "#6CDB7A",
|
||||
z: "#7AA7FF",
|
||||
x: "#FF4D4F",
|
||||
y: "#52C41A",
|
||||
z: "#1890FF",
|
||||
} as const;
|
||||
|
||||
/** 地面坐标轴的单侧长度:从原点铺到地面边缘,两侧合起来正好贯穿整块地面。 */
|
||||
|
||||
@@ -12,6 +12,12 @@ import { DoubleSide } from "three";
|
||||
*/
|
||||
export const GROUND_PLANE_SIZE = 100;
|
||||
|
||||
/** 视口网格单格尺寸(世界单位)。以网格中心为起点向四周铺开。 */
|
||||
export const GRID_CELL_SIZE = 1;
|
||||
|
||||
/** 每隔多少格绘制一条更粗、更深的分割线(主分割线)。 */
|
||||
export const GRID_SECTION_CELLS = 5;
|
||||
|
||||
/** 网格相对地面平面的抬升,避免两者共面时 z-fighting。 */
|
||||
export const GROUND_GRID_ELEVATION = 0.002;
|
||||
|
||||
|
||||
@@ -64,6 +64,173 @@ it("keeps the playhead where the export found it", async () => {
|
||||
expect(usePlaybackStore.getState().playhead).toBe(2.5);
|
||||
});
|
||||
|
||||
/** 逐帧导出在 jsdom 里需要的几个全局桩(视口画布 / WebCodecs 都没有) */
|
||||
function stubVideoCodecs() {
|
||||
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: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
/** 造一套假的渲染上下文,并记录「每一帧渲染瞬间」相机在哪儿、fov 多少 */
|
||||
function createFakeBridge() {
|
||||
const scene = new Scene();
|
||||
const camera = new PerspectiveCamera();
|
||||
const rendered: Array<{
|
||||
position: [number, number, number];
|
||||
quaternion: [number, number, number, number];
|
||||
fov: number;
|
||||
}> = [];
|
||||
const gl = {
|
||||
domElement: document.createElement("canvas"),
|
||||
render: vi.fn(() => {
|
||||
rendered.push({
|
||||
position: [camera.position.x, camera.position.y, camera.position.z],
|
||||
quaternion: [camera.quaternion.x, camera.quaternion.y, camera.quaternion.z, camera.quaternion.w],
|
||||
fov: camera.fov,
|
||||
});
|
||||
}),
|
||||
getSize: (size: Vector2) => size.set(300, 150),
|
||||
getPixelRatio: () => 1,
|
||||
setPixelRatio: () => {},
|
||||
setSize: () => {},
|
||||
};
|
||||
|
||||
contextRef.current = { gl, scene, camera };
|
||||
|
||||
return { camera, gl, scene, rendered };
|
||||
}
|
||||
|
||||
/** 给默认机位换一个明显区别于导演视角的位姿(更高 / 更远 / 更窄) */
|
||||
function placeCameraShotAt(position: [number, number, number], target: [number, number, number], fov: number) {
|
||||
const store = useDirectorStore.getState();
|
||||
const shot = store.project.cameras[0];
|
||||
store.updateCamera(shot.id, {
|
||||
transform: { position, rotation: [0, 0, 0], scale: [1, 1, 1] },
|
||||
target,
|
||||
fov,
|
||||
});
|
||||
store.setActiveCamera(shot.id);
|
||||
// 短时长,缩小导出帧数,加速测试
|
||||
store.setTimelineDuration(0.1);
|
||||
|
||||
return shot.id;
|
||||
}
|
||||
|
||||
it("renders camera-view exports from the camera shot itself when it has no camera track", async () => {
|
||||
const shotId = placeCameraShotAt([0, 3, 9], [0, 1.2, 0], 35);
|
||||
const bridge = createFakeBridge();
|
||||
stubVideoCodecs();
|
||||
|
||||
try {
|
||||
await exportVideo("camera");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
// 没有运镜轨道时 applyActiveCameraViewAtTime 不会动相机,导出必须自己把机位视角写进去,
|
||||
// 否则画面取决于「此刻 R3F 默认相机是谁」,换相机没及时落地就渲成导演视角(更高、更远)。
|
||||
const expected = new PerspectiveCamera();
|
||||
expected.position.set(0, 3, 9);
|
||||
expected.lookAt(0, 1.2, 0);
|
||||
|
||||
expect(bridge.rendered.length).toBeGreaterThan(0);
|
||||
for (const frame of bridge.rendered) {
|
||||
expect(frame.position[0]).toBeCloseTo(0, 5);
|
||||
expect(frame.position[1]).toBeCloseTo(3, 5);
|
||||
expect(frame.position[2]).toBeCloseTo(9, 5);
|
||||
expect(frame.fov).toBeCloseTo(35, 5);
|
||||
expect(frame.quaternion[0]).toBeCloseTo(expected.quaternion.x, 5);
|
||||
expect(frame.quaternion[1]).toBeCloseTo(expected.quaternion.y, 5);
|
||||
expect(frame.quaternion[2]).toBeCloseTo(expected.quaternion.z, 5);
|
||||
expect(frame.quaternion[3]).toBeCloseTo(expected.quaternion.w, 5);
|
||||
}
|
||||
// 机位自己仍在,没被导出改掉
|
||||
expect(useDirectorStore.getState().project.activeCameraId).toBe(shotId);
|
||||
});
|
||||
|
||||
it("lets the camera track win over the static shot pose in camera-view exports", async () => {
|
||||
const shotId = placeCameraShotAt([0, 3, 9], [0, 1.2, 0], 35);
|
||||
// 给它加一条运镜轨道:0s 处机位在 [5, 1, 0],和机位自身的 [0, 3, 9] 明显不同
|
||||
useDirectorStore.getState().addCameraKeyframe(shotId, 0, {
|
||||
position: [5, 1, 0],
|
||||
target: [0, 1, 0],
|
||||
fov: 40,
|
||||
});
|
||||
const bridge = createFakeBridge();
|
||||
stubVideoCodecs();
|
||||
|
||||
try {
|
||||
await exportVideo("camera");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
// 有轨道时走的是预览同款插值,导出不该再用静态机位覆盖它
|
||||
expect(bridge.rendered.length).toBeGreaterThan(0);
|
||||
for (const frame of bridge.rendered) {
|
||||
expect(frame.position[0]).toBeCloseTo(5, 5);
|
||||
expect(frame.position[1]).toBeCloseTo(1, 5);
|
||||
expect(frame.position[2]).toBeCloseTo(0, 5);
|
||||
expect(frame.fov).toBeCloseTo(40, 5);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not move the render camera to the shot when exporting the director view", async () => {
|
||||
placeCameraShotAt([0, 3, 9], [0, 1.2, 0], 35);
|
||||
const bridge = createFakeBridge();
|
||||
stubVideoCodecs();
|
||||
|
||||
try {
|
||||
await exportVideo("director");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
// 上帝视角导出就该用导演相机,不能被机位自身的视角带跑
|
||||
expect(bridge.rendered.length).toBeGreaterThan(0);
|
||||
expect(bridge.rendered.every((frame) => frame.position.every((value) => value === 0))).toBe(true);
|
||||
});
|
||||
|
||||
it("puts the render camera back where it was after a camera-view export", async () => {
|
||||
placeCameraShotAt([0, 3, 9], [0, 1.2, 0], 35);
|
||||
const bridge = createFakeBridge();
|
||||
bridge.camera.position.set(1, 2, 3);
|
||||
bridge.camera.lookAt(0, 0, 0);
|
||||
bridge.camera.fov = 60;
|
||||
const savedQuaternion = bridge.camera.quaternion.clone();
|
||||
stubVideoCodecs();
|
||||
|
||||
try {
|
||||
await exportVideo("camera");
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
// 导出期间为了「所见即所得」改写了相机,结束必须还原,否则切回导演视角会变成机位的视角
|
||||
expect(bridge.camera.position.toArray()).toEqual([1, 2, 3]);
|
||||
expect(bridge.camera.fov).toBe(60);
|
||||
expect(bridge.camera.quaternion.equals(savedQuaternion)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides editor helpers (gizmo / grid / axes) while rendering exported frames, then restores them", async () => {
|
||||
// 模拟场景里有一个「导出时隐藏」的辅助物体(也就是选中对象拖拽 gizmo 的那三条红绿蓝轴)
|
||||
const scene = new Scene();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { flushSync } from "react-dom";
|
||||
import { ArrayBufferTarget, Muxer } from "mp4-muxer";
|
||||
import { Vector2 } from "three";
|
||||
import { Vector2, type PerspectiveCamera } 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 { getCameraViewSnapshotFromShot, type CameraViewSnapshot } from "../schema/cameraGeometry";
|
||||
import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio";
|
||||
import { normalizeTimelineFps } from "../schema/timeline";
|
||||
|
||||
@@ -55,6 +56,23 @@ function waitNextFrame(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 机位没有运镜轨道时的兜底:把「这个机位自己的视角」直接写到导出相机上。
|
||||
*
|
||||
* 机位视角下 applyActiveCameraViewAtTime 只在有运镜轨道时才覆盖相机(无轨道直接 return false),
|
||||
* 也就是说无轨道时导出画面完全取决于「此刻 R3F 的默认相机是谁」。默认相机本来应该是
|
||||
* 机位视角挂载的那颗 drei 相机,但换相机要等 React/R3F 走一轮,稍有差池就会渲成
|
||||
* 导演视角(默认更高、更远)—— 所以这里不再赌它,直接按机位自身的视角写死。
|
||||
*/
|
||||
function applyCameraSnapshotToFrameCamera(camera: PerspectiveCamera, view: CameraViewSnapshot) {
|
||||
camera.position.set(view.position[0], view.position[1], view.position[2]);
|
||||
camera.lookAt(view.target[0], view.target[1], view.target[2]);
|
||||
if (Math.abs(camera.fov - view.fov) > 0.001) {
|
||||
camera.fov = view.fov;
|
||||
}
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按视频比例(viewportAspectRatio)计算导出尺寸,短边 720,宽高取偶(编码器要求)。
|
||||
* 「自动」也固定按 16:9 导出 —— 导出画面比例绝不受视口布局(如时间轴拖高压扁视口)影响,
|
||||
@@ -131,6 +149,10 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
|
||||
gl.getSize(previousSize);
|
||||
const previousPixelRatio = gl.getPixelRatio();
|
||||
const previousAspect = camera.aspect;
|
||||
// 兜底改写相机会动到位姿,导出结束要还回去(切回导演视角后不该变成机位的视角)
|
||||
const previousCameraPosition = camera.position.clone();
|
||||
const previousCameraQuaternion = camera.quaternion.clone();
|
||||
const previousCameraFov = camera.fov;
|
||||
|
||||
const muxer = new Muxer({
|
||||
target: new ArrayBufferTarget(),
|
||||
@@ -155,9 +177,19 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
// 切视角会让 R3F 更换渲染相机(机位视角挂载 drei PerspectiveCamera),
|
||||
// 等一拍让 VideoExportBridge 重新注册最新相机,导出循环里也每帧取最新引用
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
// 切视角会让 R3F 更换渲染相机(机位视角挂载 drei PerspectiveCamera)。
|
||||
// 必须等一整帧:换相机要 React 重渲染 + R3F 换默认相机 + VideoExportBridge 重新注册,
|
||||
// 只等一个宏任务不够(截图路径同样要等一帧)。导出循环里也每帧取最新引用兜底。
|
||||
await waitNextFrame();
|
||||
|
||||
// 切完视角再取当前机位:setViewMode("camera") 会在 activeCameraId 为空时补上第一个机位,
|
||||
// 所以不能用上面的 directorStore 快照(那时可能还是 null)。
|
||||
const exportState = useDirectorStore.getState();
|
||||
const activeCameraShot =
|
||||
view === "camera"
|
||||
? exportState.project.cameras.find((item) => item.id === exportState.project.activeCameraId) ?? null
|
||||
: null;
|
||||
const activeCameraView = activeCameraShot ? getCameraViewSnapshotFromShot(activeCameraShot) : null;
|
||||
|
||||
const frameDurationUs = Math.round(1_000_000 / fps);
|
||||
try {
|
||||
@@ -182,15 +214,22 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
|
||||
usePlaybackStore.getState().editingObjectId,
|
||||
{ objects: directorStore.project.objects, duration }
|
||||
);
|
||||
applyActiveCameraViewAtTime({
|
||||
// 有运镜轨道时两边共用同一份插值(预览 / 导出一致);返回 false 表示没覆盖相机
|
||||
const appliedCameraTrack = applyActiveCameraViewAtTime({
|
||||
camera: frameCamera,
|
||||
cameraTracks: directorStore.project.timeline.cameraTracks,
|
||||
activeCameraId: directorStore.project.activeCameraId,
|
||||
activeCameraId: exportState.project.activeCameraId,
|
||||
playhead: time,
|
||||
viewMode: view,
|
||||
editingObjectId: usePlaybackStore.getState().editingObjectId,
|
||||
});
|
||||
|
||||
// 没覆盖 = 这个机位没有运镜轨道。此时必须把机位自己的视角写进导出相机,
|
||||
// 否则画面取决于「此刻默认相机是谁」,换相机稍有差池就渲成导演视角(更高、更远)。
|
||||
if (!appliedCameraTrack && activeCameraView) {
|
||||
applyCameraSnapshotToFrameCamera(frameCamera, activeCameraView);
|
||||
}
|
||||
|
||||
// 和截图同一套:渲染这一帧前先把编辑器辅助元素(gizmo 红绿蓝轴 / 网格 / 坐标轴 / 轨迹)藏起来,
|
||||
// 否则导出的视频里会带着选中的 gizmo 三条轴。渲完 withViewportCaptureHelpersHidden 会原样恢复。
|
||||
withViewportCaptureHelpersHidden(scene, () => gl.render(scene, frameCamera));
|
||||
@@ -222,9 +261,24 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
|
||||
canvas.classList.remove("is-video-exporting");
|
||||
gl.setPixelRatio(previousPixelRatio);
|
||||
gl.setSize(previousSize.x, previousSize.y, false);
|
||||
|
||||
// 导出期间相机可能换过(切视角 → drei 挂载机位相机),两颗都按导出前的样子还原:
|
||||
// - aspect 两颗都要还,否则切回导演视角后画面比例还停在导出的 16:9;
|
||||
// - 位姿只在「导出用的就是导出前那颗相机」时还,否则等于把导演相机的位姿
|
||||
// 写到机位相机上,机位视角就会被带歪。
|
||||
const restoreCamera = getVideoExportContext()?.camera ?? camera;
|
||||
restoreCamera.aspect = previousAspect;
|
||||
restoreCamera.updateProjectionMatrix();
|
||||
camera.aspect = previousAspect;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
if (restoreCamera === camera) {
|
||||
restoreCamera.position.copy(previousCameraPosition);
|
||||
restoreCamera.quaternion.copy(previousCameraQuaternion);
|
||||
restoreCamera.fov = previousCameraFov;
|
||||
restoreCamera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
directorStore.setViewMode(previousViewMode);
|
||||
usePlaybackStore.getState().setPlayhead(previousPlayhead);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Camera, Download, Eye, Images, Send, Trash2, X, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { Camera, ChevronLeft, ChevronRight, Download, Eye, Images, Send, Trash2, X, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
@@ -20,6 +20,9 @@ import { useDirectorStore } from "../store/directorStore";
|
||||
const VIEWER_ZOOM_MIN = 0.25;
|
||||
const VIEWER_ZOOM_MAX = 5;
|
||||
const VIEWER_ZOOM_STEP = 0.25;
|
||||
/** 可拖动预览窗口的最小尺寸(手动调节时不允许小于该值) */
|
||||
const VIEWER_DIALOG_MIN_WIDTH = 360;
|
||||
const VIEWER_DIALOG_MIN_HEIGHT = 300;
|
||||
/** 机位重置值:默认机位的位置 / 注视点(与新建项目里 1 号机位一致) */
|
||||
const DEFAULT_CAMERA_SHOT_POSITION = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.position;
|
||||
const DEFAULT_CAMERA_SHOT_TARGET = DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.target;
|
||||
@@ -42,6 +45,23 @@ export function CameraPanel() {
|
||||
originX: number;
|
||||
originY: number;
|
||||
} | null>(null);
|
||||
const [viewerDialogOffset, setViewerDialogOffset] = useState({ x: 0, y: 0 });
|
||||
const [viewerDialogDragging, setViewerDialogDragging] = useState(false);
|
||||
const viewerDialogDragStateRef = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
} | null>(null);
|
||||
const [viewerDialogSize, setViewerDialogSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [viewerDialogResizing, setViewerDialogResizing] = useState(false);
|
||||
const viewerDialogResizeStateRef = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
originWidth: number;
|
||||
originHeight: number;
|
||||
} | null>(null);
|
||||
const viewerDialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const camera = useDirectorStore((state) =>
|
||||
state.project.cameras.find((item) => item.id === state.project.activeCameraId)
|
||||
);
|
||||
@@ -75,6 +95,12 @@ export function CameraPanel() {
|
||||
setViewerOffset({ x: 0, y: 0 });
|
||||
setViewerDragging(false);
|
||||
viewerDragStateRef.current = null;
|
||||
setViewerDialogOffset({ x: 0, y: 0 });
|
||||
setViewerDialogDragging(false);
|
||||
viewerDialogDragStateRef.current = null;
|
||||
setViewerDialogSize(null);
|
||||
setViewerDialogResizing(false);
|
||||
viewerDialogResizeStateRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -127,6 +153,121 @@ export function CameraPanel() {
|
||||
};
|
||||
}, [viewerDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerDialogDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
const dragState = viewerDialogDragStateRef.current;
|
||||
if (!dragState) {
|
||||
return;
|
||||
}
|
||||
|
||||
setViewerDialogOffset({
|
||||
x: dragState.originX + event.clientX - dragState.startX,
|
||||
y: dragState.originY + event.clientY - dragState.startY,
|
||||
});
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
setViewerDialogDragging(false);
|
||||
viewerDialogDragStateRef.current = null;
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [viewerDialogDragging]);
|
||||
|
||||
function handleViewerDialogMouseDown(event: React.MouseEvent<HTMLDivElement>) {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
// 标题栏上的工具按钮(放大/缩小/下载/关闭/前后切换)不应触发拖动
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest(".camera-capture-viewer-toolbar")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
viewerDialogDragStateRef.current = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originX: viewerDialogOffset.x,
|
||||
originY: viewerDialogOffset.y,
|
||||
};
|
||||
setViewerDialogDragging(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerDialogResizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
const resizeState = viewerDialogResizeStateRef.current;
|
||||
if (!resizeState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxWidth = Math.max(VIEWER_DIALOG_MIN_WIDTH, window.innerWidth - 32);
|
||||
const maxHeight = Math.max(VIEWER_DIALOG_MIN_HEIGHT, window.innerHeight - 32);
|
||||
const width = Math.min(maxWidth, Math.max(VIEWER_DIALOG_MIN_WIDTH, resizeState.originWidth + event.clientX - resizeState.startX));
|
||||
const height = Math.min(maxHeight, Math.max(VIEWER_DIALOG_MIN_HEIGHT, resizeState.originHeight + event.clientY - resizeState.startY));
|
||||
|
||||
setViewerDialogSize({ width, height });
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
setViewerDialogResizing(false);
|
||||
viewerDialogResizeStateRef.current = null;
|
||||
}
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [viewerDialogResizing]);
|
||||
|
||||
function handleViewerDialogResizeMouseDown(event: React.MouseEvent<HTMLDivElement>) {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const el = viewerDialogRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
viewerDialogResizeStateRef.current = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originWidth: rect.width,
|
||||
originHeight: rect.height,
|
||||
};
|
||||
setViewerDialogResizing(true);
|
||||
}
|
||||
|
||||
function handleViewerNavigate(capture: DirectorCameraCapture | null) {
|
||||
if (!capture) {
|
||||
return;
|
||||
}
|
||||
setViewerCapture(capture);
|
||||
// 切换截图时重置缩放与平移,避免上一张的缩放状态带到下一张
|
||||
setViewerScale(1);
|
||||
setViewerOffset({ x: 0, y: 0 });
|
||||
setViewerDragging(false);
|
||||
viewerDragStateRef.current = null;
|
||||
}
|
||||
|
||||
const clampViewerScale = useCallback((value: number) => {
|
||||
return Math.min(VIEWER_ZOOM_MAX, Math.max(VIEWER_ZOOM_MIN, value));
|
||||
}, []);
|
||||
@@ -408,71 +549,121 @@ export function CameraPanel() {
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
// 在所有机位的截图列表中前后切换(按总览里的分组顺序排列)
|
||||
const allCaptures = cameraCaptureGroups.flatMap((group) => group.captures);
|
||||
const currentCaptureIndex = allCaptures.findIndex((item) => item.id === viewerCapture.id);
|
||||
const prevCapture = currentCaptureIndex > 0 ? allCaptures[currentCaptureIndex - 1] : null;
|
||||
const nextCapture = currentCaptureIndex >= 0 && currentCaptureIndex < allCaptures.length - 1 ? allCaptures[currentCaptureIndex + 1] : null;
|
||||
|
||||
// 用 portal 挂到 body:侧栏卡片带 backdrop-filter,会成为 fixed 元素的包含块,
|
||||
// 直接挂在面板里的话这个「position: fixed; inset: 0」的查看器会被缩在侧栏内并被裁掉,
|
||||
// 看起来就不是弹出层了。
|
||||
// 直接挂在面板里的话这个查看器会被缩在侧栏内并被裁掉,看起来就不是弹出层了。
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
aria-label="相机截图查看器"
|
||||
aria-modal="true"
|
||||
className="camera-capture-viewer"
|
||||
role="dialog"
|
||||
onClick={closeViewer}
|
||||
>
|
||||
<>
|
||||
<div className="camera-capture-viewer-backdrop" aria-hidden="true" onClick={closeViewer} />
|
||||
<div
|
||||
aria-label="相机截图查看器工具栏"
|
||||
className="camera-capture-viewer-toolbar"
|
||||
role="toolbar"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
ref={viewerDialogRef}
|
||||
aria-label="相机截图查看器"
|
||||
aria-modal="true"
|
||||
className="camera-capture-viewer"
|
||||
role="dialog"
|
||||
style={{
|
||||
width: viewerDialogSize ? `${viewerDialogSize.width}px` : undefined,
|
||||
height: viewerDialogSize ? `${viewerDialogSize.height}px` : undefined,
|
||||
transform: `translate(calc(-50% + ${viewerDialogOffset.x}px), calc(-50% + ${viewerDialogOffset.y}px))`,
|
||||
}}
|
||||
onClick={closeViewer}
|
||||
>
|
||||
<button
|
||||
aria-label="放大图片"
|
||||
className="camera-capture-viewer-tool"
|
||||
type="button"
|
||||
onClick={() => handleViewerZoom("in")}
|
||||
>
|
||||
<ZoomIn aria-hidden="true" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="缩小图片"
|
||||
className="camera-capture-viewer-tool"
|
||||
type="button"
|
||||
onClick={() => handleViewerZoom("out")}
|
||||
>
|
||||
<ZoomOut aria-hidden="true" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="下载图片"
|
||||
className="camera-capture-viewer-tool"
|
||||
type="button"
|
||||
onClick={() => downloadDataUrl(viewerCapture.dataUrl, `${viewerCapture.name}.png`)}
|
||||
>
|
||||
<Download aria-hidden="true" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="关闭相机截图查看器"
|
||||
className="camera-capture-viewer-tool camera-capture-viewer-close"
|
||||
type="button"
|
||||
onClick={closeViewer}
|
||||
>
|
||||
<X aria-hidden="true" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="camera-capture-viewer-stage">
|
||||
<img
|
||||
className={viewerImageClassName}
|
||||
alt={`${viewerCapture.name} 查看大图`}
|
||||
src={viewerCapture.dataUrl}
|
||||
style={{ transform: `translate(${viewerOffset.x}px, ${viewerOffset.y}px) scale(${viewerScale})` }}
|
||||
<div
|
||||
aria-label="相机截图查看器标题栏"
|
||||
className={`camera-capture-viewer-header${viewerDialogDragging ? " is-dragging" : ""}`}
|
||||
role="presentation"
|
||||
onMouseDown={handleViewerDialogMouseDown}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className="camera-capture-viewer-title">{viewerCapture.name}</span>
|
||||
<span className="camera-capture-viewer-counter">
|
||||
{allCaptures.length > 0 ? `${currentCaptureIndex + 1} / ${allCaptures.length}` : ""}
|
||||
</span>
|
||||
<div
|
||||
aria-label="相机截图查看器工具栏"
|
||||
className="camera-capture-viewer-toolbar"
|
||||
role="toolbar"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
aria-label="上一张截图"
|
||||
className="camera-capture-viewer-tool"
|
||||
type="button"
|
||||
disabled={!prevCapture}
|
||||
onClick={() => handleViewerNavigate(prevCapture)}
|
||||
>
|
||||
<ChevronLeft aria-hidden="true" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="下一张截图"
|
||||
className="camera-capture-viewer-tool"
|
||||
type="button"
|
||||
disabled={!nextCapture}
|
||||
onClick={() => handleViewerNavigate(nextCapture)}
|
||||
>
|
||||
<ChevronRight aria-hidden="true" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="放大图片"
|
||||
className="camera-capture-viewer-tool"
|
||||
type="button"
|
||||
onClick={() => handleViewerZoom("in")}
|
||||
>
|
||||
<ZoomIn aria-hidden="true" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="缩小图片"
|
||||
className="camera-capture-viewer-tool"
|
||||
type="button"
|
||||
onClick={() => handleViewerZoom("out")}
|
||||
>
|
||||
<ZoomOut aria-hidden="true" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="下载图片"
|
||||
className="camera-capture-viewer-tool"
|
||||
type="button"
|
||||
onClick={() => downloadDataUrl(viewerCapture.dataUrl, `${viewerCapture.name}.png`)}
|
||||
>
|
||||
<Download aria-hidden="true" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="关闭相机截图查看器"
|
||||
className="camera-capture-viewer-tool camera-capture-viewer-close"
|
||||
type="button"
|
||||
onClick={closeViewer}
|
||||
>
|
||||
<X aria-hidden="true" size={18} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="camera-capture-viewer-stage">
|
||||
<img
|
||||
className={viewerImageClassName}
|
||||
alt={`${viewerCapture.name} 查看大图`}
|
||||
src={viewerCapture.dataUrl}
|
||||
style={{ transform: `translate(${viewerOffset.x}px, ${viewerOffset.y}px) scale(${viewerScale})` }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onWheel={handleViewerWheel}
|
||||
onMouseDown={handleViewerMouseDown}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`camera-capture-viewer-resize${viewerDialogResizing ? " is-resizing" : ""}`}
|
||||
onMouseDown={handleViewerDialogResizeMouseDown}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onWheel={handleViewerWheel}
|
||||
onMouseDown={handleViewerMouseDown}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ export function ScenePanel() {
|
||||
/>
|
||||
<span>角色标签</span>
|
||||
</div>
|
||||
<div className="inspector-toggle-row">
|
||||
<div className="inspector-toggle-row toggle-wide">
|
||||
<input
|
||||
aria-label="截图/录制时显示网格"
|
||||
checked={scene.captureShowGrid}
|
||||
|
||||
@@ -18,6 +18,13 @@ function getDefaultCharacter() {
|
||||
return useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
|
||||
}
|
||||
|
||||
/** 加一个非角色物体(几何体)用于「物体轨道」相关测试,避免角色被渲染成两条轨道 */
|
||||
function addPropObject() {
|
||||
useDirectorStore.getState().addGeometryPrimitive("box");
|
||||
const objects = useDirectorStore.getState().project.objects;
|
||||
return objects[objects.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom 不做真实布局(所有 getBoundingClientRect 都是 0):
|
||||
* - 轨道拖拽排序要靠行几何算落点 → 按 data-track-key 造一份给定布局;
|
||||
@@ -205,7 +212,7 @@ it("disables add-keyframe button when no object is selected", () => {
|
||||
});
|
||||
|
||||
it("adds an object keyframe for the selected object at the playhead", () => {
|
||||
const objectId = useDirectorStore.getState().project.objects[0].id;
|
||||
const objectId = addPropObject().id;
|
||||
useDirectorStore.getState().selectObject(objectId);
|
||||
usePlaybackStore.getState().setPlayhead(2);
|
||||
|
||||
@@ -232,7 +239,7 @@ it("adds a camera keyframe when a manual camera is selected", () => {
|
||||
});
|
||||
|
||||
it("deletes a selected keyframe", () => {
|
||||
const objectId = useDirectorStore.getState().project.objects[0].id;
|
||||
const objectId = addPropObject().id;
|
||||
useDirectorStore.getState().selectObject(objectId);
|
||||
usePlaybackStore.getState().setPlayhead(1);
|
||||
render(<TimelinePanel />);
|
||||
@@ -245,20 +252,21 @@ it("deletes a selected keyframe", () => {
|
||||
expect(useDirectorStore.getState().project.timeline.tracks).toEqual([]);
|
||||
});
|
||||
|
||||
it("deletes a keyframe via the per-keyframe × button", () => {
|
||||
const objectId = useDirectorStore.getState().project.objects[0].id;
|
||||
it("deletes a keyframe after selecting it", () => {
|
||||
const objectId = addPropObject().id;
|
||||
useDirectorStore.getState().selectObject(objectId);
|
||||
usePlaybackStore.getState().setPlayhead(1);
|
||||
render(<TimelinePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除关键帧 1.0s" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "关键帧 1.0s" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除选中关键帧" }));
|
||||
|
||||
expect(useDirectorStore.getState().project.timeline.tracks).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears all keyframes via the clear-all button", () => {
|
||||
const objectId = useDirectorStore.getState().project.objects[0].id;
|
||||
const objectId = addPropObject().id;
|
||||
useDirectorStore.getState().selectObject(objectId);
|
||||
render(<TimelinePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
|
||||
@@ -317,6 +325,35 @@ it("adds a character track with motion and trajectory sub-tracks", () => {
|
||||
expect(screen.getByRole("button", { name: "轨迹关键帧 5.0s" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a single track for a character, never a duplicate object track", () => {
|
||||
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
|
||||
// 即便角色带有一条 transform 关键帧轨道(历史数据 / 误加),也不应再渲染成「物体轨道」
|
||||
useDirectorStore.getState().addKeyframe(character.id, 1, {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
});
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
|
||||
|
||||
const { container } = render(<TimelinePanel />);
|
||||
|
||||
// 只有一条「角色轨道」,且不存在以该角色 id 命名的「物体轨道」
|
||||
expect(container.querySelectorAll(".timeline-character-track")).toHaveLength(1);
|
||||
expect(container.querySelector(`.timeline-track[data-track-key="object:${character.id}"]`)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not create a transform track when adding a keyframe to a selected character", () => {
|
||||
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
|
||||
useDirectorStore.getState().selectObject(character.id);
|
||||
usePlaybackStore.getState().setPlayhead(1);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
|
||||
|
||||
const track = useDirectorStore.getState().project.timeline.tracks.find((t) => t.objectId === character.id);
|
||||
expect(track).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows the character motion kind on the motion sub-track", () => {
|
||||
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
|
||||
useDirectorStore.getState().setMotion(character.id, "walk");
|
||||
@@ -344,13 +381,71 @@ it("selects a trajectory point and seeks the playhead when its timeline keyframe
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a trajectory point from its timeline keyframe", () => {
|
||||
it("clears the trajectory keyframe selection when clicking the track background", () => {
|
||||
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
|
||||
|
||||
const { container } = render(<TimelinePanel />);
|
||||
const keyframe = screen.getByRole("button", { name: "轨迹关键帧 2.5s" });
|
||||
fireEvent.click(keyframe);
|
||||
expect(keyframe).toHaveClass("is-selected");
|
||||
expect(useDirectorStore.getState().selectedTrajectoryPoint).not.toBeNull();
|
||||
|
||||
// 点轨道空白处:选中态取消(角色本身的选择不受影响)
|
||||
fireEvent.pointerDown(container.querySelector<HTMLElement>(".timeline-grid-body")!, {
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
});
|
||||
|
||||
expect(useDirectorStore.getState().selectedTrajectoryPoint).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "轨迹关键帧 2.5s" })).not.toHaveClass("is-selected");
|
||||
expect(useDirectorStore.getState().selectedObjectId).toBe(character.id);
|
||||
});
|
||||
|
||||
it("moves focus off the trajectory keyframe when clicking elsewhere", () => {
|
||||
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除轨迹关键帧 5.0s" }));
|
||||
const keyframe = screen.getByRole("button", { name: "轨迹关键帧 2.5s" });
|
||||
fireEvent.click(keyframe);
|
||||
keyframe.focus();
|
||||
expect(document.activeElement).toBe(keyframe);
|
||||
|
||||
fireEvent.pointerDown(document.body, { button: 0, pointerId: 1 });
|
||||
|
||||
// 焦点从已取消选中的关键帧上移走,焦点框不再留在它身上
|
||||
expect(document.activeElement).not.toBe(keyframe);
|
||||
});
|
||||
|
||||
it("keeps the trajectory keyframe selected when clicking the keyframe itself", () => {
|
||||
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
const keyframe = screen.getByRole("button", { name: "轨迹关键帧 2.5s" });
|
||||
fireEvent.click(keyframe);
|
||||
|
||||
// 再点自己:那是选中 / 拖它改时间,不该被当成「点到别处」清掉
|
||||
fireEvent.pointerDown(keyframe, { button: 0, pointerId: 1 });
|
||||
|
||||
expect(useDirectorStore.getState().selectedTrajectoryPoint).not.toBeNull();
|
||||
expect(keyframe).toHaveClass("is-selected");
|
||||
});
|
||||
|
||||
it("deletes a selected trajectory point with the Delete key", () => {
|
||||
const character = useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!;
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
// 轨迹点没有独立删除按钮:选中后按 Delete 删除
|
||||
const lastKeyframe = screen.getByRole("button", { name: "轨迹关键帧 5.0s" });
|
||||
fireEvent.click(lastKeyframe);
|
||||
fireEvent.keyDown(lastKeyframe, { key: "Delete" });
|
||||
|
||||
const points = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!.trajectoryPoints;
|
||||
expect(points).toHaveLength(1);
|
||||
@@ -383,8 +478,11 @@ it("keeps the track order untouched when a drop would not change it", () => {
|
||||
|
||||
/** 渲染一个「物体轨道 + 角色轨道」的时间轴,并给两行造好纵向布局 */
|
||||
function renderTwoTrackTimeline() {
|
||||
const character = getDefaultCharacter();
|
||||
useDirectorStore.getState().selectObject(character.id);
|
||||
// 用「几何体」造物体轨道:角色只应有角色轨道,不应再被渲染成一条物体轨道
|
||||
useDirectorStore.getState().addGeometryPrimitive("box");
|
||||
const objects = useDirectorStore.getState().project.objects;
|
||||
const object = objects[objects.length - 1];
|
||||
useDirectorStore.getState().selectObject(object.id);
|
||||
usePlaybackStore.getState().setPlayhead(1);
|
||||
|
||||
const rendered = render(<TimelinePanel />);
|
||||
@@ -392,14 +490,16 @@ function renderTwoTrackTimeline() {
|
||||
|
||||
const container = rendered.container;
|
||||
const objectRow = container.querySelector<HTMLElement>(".timeline-grid-body > .timeline-track")!;
|
||||
const character = getDefaultCharacter();
|
||||
const characterRow = container.querySelector<HTMLElement>(".timeline-character-track")!;
|
||||
|
||||
stubTrackRowLayout({
|
||||
[`object:${character.id}`]: { top: 0, height: 28 },
|
||||
[`object:${object.id}`]: { top: 0, height: 28 },
|
||||
[`character:${character.id}`]: { top: 28, height: 84 },
|
||||
});
|
||||
|
||||
return {
|
||||
object,
|
||||
character,
|
||||
container,
|
||||
objectRow,
|
||||
@@ -416,8 +516,8 @@ function beginTrackDrag(row: HTMLElement, clientY = 10) {
|
||||
}
|
||||
|
||||
it("reorders tracks by dragging a whole track row", () => {
|
||||
const { character, objectRow, characterRow, panel, indicator } = renderTwoTrackTimeline();
|
||||
const objectTrackKey = `object:${character.id}`;
|
||||
const { object, character, objectRow, characterRow, panel, indicator } = renderTwoTrackTimeline();
|
||||
const objectTrackKey = `object:${object.id}`;
|
||||
const characterTrackKey = `character:${character.id}`;
|
||||
|
||||
// 默认顺序:物体轨道在前,角色轨道在后
|
||||
@@ -480,8 +580,8 @@ it("keeps repeating pointermove at the same drop position idempotent", () => {
|
||||
});
|
||||
|
||||
it("does not start a track drag from the collapse button", () => {
|
||||
const { character, objectRow, panel } = renderTwoTrackTimeline();
|
||||
const collapseButton = screen.getByRole("button", { name: `折叠物体轨道(${character.name})` });
|
||||
const { object, objectRow, panel } = renderTwoTrackTimeline();
|
||||
const collapseButton = screen.getByRole("button", { name: `折叠物体轨道(${object.name})` });
|
||||
|
||||
fireEvent.pointerDown(collapseButton, { button: 0, pointerId: 1, clientX: 10, clientY: 10 });
|
||||
fireEvent.pointerMove(collapseButton, { pointerId: 1, clientX: 10, clientY: 90 });
|
||||
@@ -497,40 +597,120 @@ it("renders a trajectory bar whose right handle shows the walk duration", () =>
|
||||
|
||||
const { container } = render(<TimelinePanel />);
|
||||
|
||||
// 2 个轨迹点在 5s 时间轴上均分 → 走到 5s
|
||||
const handle = screen.getByRole("slider", { name: `${character.name} 轨迹时长` });
|
||||
// 2 个轨迹点在 5s 时间轴上均分 → 走到 5s;停留结束默认就是 5s
|
||||
const handle = screen.getByRole("slider", { name: `${character.name} 轨迹停留时长` });
|
||||
expect(handle).toHaveAttribute("aria-valuenow", "5");
|
||||
expect(container.querySelector(".timeline-trajectory-bar")?.textContent).toBe("5.0s");
|
||||
// 条宽度 = 走完时间占整条时间轴的比例
|
||||
// 条宽度 = 停留结束占整条时间轴的比例
|
||||
expect(container.querySelector<HTMLElement>(".timeline-trajectory-bar")?.style.width).toBe("100%");
|
||||
});
|
||||
|
||||
it("drags the duration handle to rescale every trajectory point", () => {
|
||||
it("drags the duration handle to change the trajectory hold duration", () => {
|
||||
const character = getDefaultCharacter();
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]);
|
||||
// 最后一点落在 3s,停留结束仍默认到 5s,留出 2s 停留段
|
||||
useDirectorStore.getState().setTrajectoryPointTimes(character.id, [1.5, 3]);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
// 5s 时间轴对应 500px 轨道 → 1px = 0.01s
|
||||
stubTrackRowLayout({}, 500);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${character.name} 轨迹时长` });
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 400 });
|
||||
const handle = screen.getByRole("slider", { name: `${character.name} 轨迹停留时长` });
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 500 });
|
||||
// 拖轨迹时冻结全站毛玻璃,避免背后 WebGL 画布反复重算模糊
|
||||
expect(document.body).toHaveClass("is-timeline-dragging");
|
||||
|
||||
// 往左拖 100px → 时长 5s → 4s,两个点按比例缩到 2s / 4s
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 300 });
|
||||
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 300 });
|
||||
// 把手在条右端(5s / 100%);往左拖 100px = -1s → 停留结束 4s,各轨迹点不动
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 400 });
|
||||
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 400 });
|
||||
|
||||
const points = getTrajectoryPoints(character.id);
|
||||
expect(points[0].time).toBeCloseTo(2, 5);
|
||||
expect(points[1].time).toBeCloseTo(4, 5);
|
||||
expect(points[0].time).toBeCloseTo(1.5, 5);
|
||||
expect(points[1].time).toBeCloseTo(3, 5);
|
||||
const obj = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!;
|
||||
expect(obj.trajectoryEndTime).toBeCloseTo(4, 5);
|
||||
expect(document.body).not.toHaveClass("is-timeline-dragging");
|
||||
// 拖滑块不应被当成「轨道拖动排序」
|
||||
expect(useDirectorStore.getState().timelineTrackOrder).toEqual([]);
|
||||
});
|
||||
|
||||
it("drags the left trim handle to delay the clip start without moving the points", () => {
|
||||
const character = getDefaultCharacter();
|
||||
const objectId = character.id;
|
||||
useDirectorStore.getState().addTrajectoryPoint(objectId, [1, 0, 0]);
|
||||
useDirectorStore.getState().addTrajectoryPoint(objectId, [2, 0, 0]);
|
||||
// 两个点落在 1.5s / 3s,片段起点默认 0s,左边留出可修剪的空间
|
||||
useDirectorStore.getState().setTrajectoryPointTimes(objectId, [1.5, 3]);
|
||||
|
||||
const { container } = render(<TimelinePanel />);
|
||||
stubTrackRowLayout({}, 500);
|
||||
|
||||
const bar = container.querySelector<HTMLElement>(".timeline-character-head-lane .timeline-trajectory-bar")!;
|
||||
// 左右两端各有一条修剪把手
|
||||
expect(bar.querySelectorAll(".timeline-trajectory-trim")).toHaveLength(2);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${character.name} 轨迹起点` });
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 0 });
|
||||
// 往右拖 100px = +1s → 起点推后到 1s(角色先原地等 1s),两个轨迹点留在原地
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 100 });
|
||||
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 100 });
|
||||
|
||||
const obj = useDirectorStore.getState().project.objects.find((item) => item.id === objectId)!;
|
||||
expect(obj.trajectoryStartTime).toBeCloseTo(1, 5);
|
||||
const points = getTrajectoryPoints(objectId);
|
||||
expect(points[0].time).toBeCloseTo(1.5, 5);
|
||||
expect(points[1].time).toBeCloseTo(3, 5);
|
||||
// 停留结束不受影响,仍是时间轴末尾
|
||||
expect(obj.trajectoryEndTime).toBeUndefined();
|
||||
// 拖把手不应被当成「轨道拖动排序」
|
||||
expect(useDirectorStore.getState().timelineTrackOrder).toEqual([]);
|
||||
});
|
||||
|
||||
it("clamps the trim-start handle so it cannot overrun the first trajectory point", () => {
|
||||
const character = getDefaultCharacter();
|
||||
const objectId = character.id;
|
||||
useDirectorStore.getState().addTrajectoryPoint(objectId, [1, 0, 0]);
|
||||
useDirectorStore.getState().addTrajectoryPoint(objectId, [2, 0, 0]);
|
||||
useDirectorStore.getState().setTrajectoryPointTimes(objectId, [1.5, 3]);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
stubTrackRowLayout({}, 500);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${character.name} 轨迹起点` });
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 0 });
|
||||
// 一路拖过第一个点也不让它越过去:最多到 1.45s(第一个点前留一个最小间隔)
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 400 });
|
||||
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 400 });
|
||||
|
||||
const obj = useDirectorStore.getState().project.objects.find((item) => item.id === objectId)!;
|
||||
expect(obj.trajectoryStartTime).toBeCloseTo(1.45, 5);
|
||||
const points = getTrajectoryPoints(objectId);
|
||||
expect(points[0].time).toBeCloseTo(1.5, 5);
|
||||
expect(points[1].time).toBeCloseTo(3, 5);
|
||||
});
|
||||
|
||||
it("nudges the trim-start handle with the keyboard", () => {
|
||||
const character = getDefaultCharacter();
|
||||
const objectId = character.id;
|
||||
useDirectorStore.getState().addTrajectoryPoint(objectId, [1, 0, 0]);
|
||||
useDirectorStore.getState().addTrajectoryPoint(objectId, [2, 0, 0]);
|
||||
useDirectorStore.getState().setTrajectoryPointTimes(objectId, [1.5, 3]);
|
||||
useDirectorStore.getState().setTrajectoryClipTrimStart(objectId, 1);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${character.name} 轨迹起点` });
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
|
||||
const obj = useDirectorStore.getState().project.objects.find((item) => item.id === objectId)!;
|
||||
// 1s → 0.9s,各轨迹点不动
|
||||
expect(obj.trajectoryStartTime).toBeCloseTo(0.9, 5);
|
||||
const points = getTrajectoryPoints(objectId);
|
||||
expect(points[0].time).toBeCloseTo(1.5, 5);
|
||||
expect(points[1].time).toBeCloseTo(3, 5);
|
||||
});
|
||||
|
||||
it("drags a single trajectory keyframe to change only its own time", () => {
|
||||
const character = getDefaultCharacter();
|
||||
useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]);
|
||||
@@ -553,20 +733,23 @@ it("drags a single trajectory keyframe to change only its own time", () => {
|
||||
expect(screen.getByRole("button", { name: "轨迹关键帧 3.0s" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("nudges the trajectory duration with the keyboard", () => {
|
||||
it("nudges the trajectory hold duration with the keyboard", () => {
|
||||
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]);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${character.name} 轨迹时长` });
|
||||
const handle = screen.getByRole("slider", { name: `${character.name} 轨迹停留时长` });
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
|
||||
const points = getTrajectoryPoints(character.id);
|
||||
// 时长 5s → 4.9s,各点等比缩放
|
||||
expect(points[1].time).toBeCloseTo(4.9, 5);
|
||||
expect(points[0].time).toBeCloseTo(2.45, 5);
|
||||
// 停留结束 5s → 4.9s,各轨迹点不动
|
||||
expect(points[1].time).toBeCloseTo(3, 5);
|
||||
expect(points[0].time).toBeCloseTo(1.5, 5);
|
||||
const obj = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!;
|
||||
expect(obj.trajectoryEndTime).toBeCloseTo(4.9, 5);
|
||||
});
|
||||
|
||||
it("collapses and expands a character track", () => {
|
||||
@@ -579,7 +762,7 @@ it("collapses and expands a character track", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: `折叠角色轨道(${character.name})` }));
|
||||
|
||||
// 折叠后只剩抬头行,动作 / 轨迹子轨道都收起来
|
||||
// 折叠后只剩抬头行,动作 / 轨迹子轨道都收起来(轨迹条在抬头行里,轨迹子轨道也折叠)
|
||||
expect(container.querySelectorAll(".timeline-subtrack")).toHaveLength(0);
|
||||
expect(container.querySelector(".timeline-character-track")).toHaveClass("is-collapsed");
|
||||
expect(container.querySelector(".timeline-character-head-lane")?.textContent).toContain("1 个轨迹点");
|
||||
@@ -595,20 +778,22 @@ it("collapses and expands a character track", () => {
|
||||
});
|
||||
|
||||
it("gives every track a collapse toggle, even when it has no sub-tracks", () => {
|
||||
const character = getDefaultCharacter();
|
||||
useDirectorStore.getState().selectObject(character.id);
|
||||
useDirectorStore.getState().addGeometryPrimitive("box");
|
||||
const objects = useDirectorStore.getState().project.objects;
|
||||
const object = objects[objects.length - 1];
|
||||
useDirectorStore.getState().selectObject(object.id);
|
||||
usePlaybackStore.getState().setPlayhead(1);
|
||||
|
||||
const { container } = render(<TimelinePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
|
||||
|
||||
// 物体轨道没有子轨道,同样带折叠开关
|
||||
fireEvent.click(screen.getByRole("button", { name: `折叠物体轨道(${character.name})` }));
|
||||
fireEvent.click(screen.getByRole("button", { name: `折叠物体轨道(${object.name})` }));
|
||||
|
||||
expect(container.querySelector(".timeline-grid-body > .timeline-track")).toHaveClass("is-collapsed");
|
||||
expect(container.querySelector(".timeline-track-summary")?.textContent).toBe("1 个关键帧");
|
||||
expect(screen.queryByRole("button", { name: "关键帧 1.0s" })).not.toBeInTheDocument();
|
||||
expect(useDirectorStore.getState().timelineCollapsedTracks).toEqual([`object:${character.id}`]);
|
||||
expect(useDirectorStore.getState().timelineCollapsedTracks).toEqual([`object:${object.id}`]);
|
||||
});
|
||||
|
||||
it("gives the camera track a collapse toggle as well", () => {
|
||||
@@ -700,6 +885,64 @@ it("drags the camera clip handle to rescale the whole camera move", () => {
|
||||
expect(document.body).not.toHaveClass("is-timeline-dragging");
|
||||
});
|
||||
|
||||
it("drags the camera clip left handle to trim the start while the last keyframe stays put", () => {
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
addCameraKeyframeAt(camera.id, 1);
|
||||
addCameraKeyframeAt(camera.id, 4);
|
||||
|
||||
const { container } = render(<TimelinePanel />);
|
||||
stubTrackRowLayout({}, 500);
|
||||
|
||||
const bar = container.querySelector<HTMLElement>(".timeline-clip-bar-camera")!;
|
||||
// 左右两端各有一条修剪把手
|
||||
expect(bar.querySelectorAll(".timeline-trajectory-trim")).toHaveLength(2);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 运镜起点` });
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 100 });
|
||||
// 往右拖 100px = +1s → 运镜推迟到 2s 才开始,末帧仍停在 4s
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 200 });
|
||||
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 200 });
|
||||
|
||||
const times = getCameraKeyframeTimes(camera.id);
|
||||
expect(times[0]).toBeCloseTo(2, 5);
|
||||
expect(times[1]).toBeCloseTo(4, 5);
|
||||
});
|
||||
|
||||
it("clamps the camera trim-start handle so it cannot overrun the last keyframe", () => {
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
addCameraKeyframeAt(camera.id, 1);
|
||||
addCameraKeyframeAt(camera.id, 4);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
stubTrackRowLayout({}, 500);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 运镜起点` });
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 100 });
|
||||
// 一路拖到末帧之后也不越过去:最多到 3.95s(末帧前留一个最小间隔)
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 500 });
|
||||
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 500 });
|
||||
|
||||
const times = getCameraKeyframeTimes(camera.id);
|
||||
expect(times[0]).toBeCloseTo(3.95, 5);
|
||||
expect(times[1]).toBeCloseTo(4, 5);
|
||||
});
|
||||
|
||||
it("nudges the camera clip start with the arrow keys", () => {
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
addCameraKeyframeAt(camera.id, 1);
|
||||
addCameraKeyframeAt(camera.id, 4);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 运镜起点` });
|
||||
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
||||
|
||||
const times = getCameraKeyframeTimes(camera.id);
|
||||
// 起点 1s → 1.1s,末帧不动
|
||||
expect(times[0]).toBeCloseTo(1.1, 5);
|
||||
expect(times[1]).toBeCloseTo(4, 5);
|
||||
});
|
||||
|
||||
it("nudges the camera clip duration with the arrow keys", () => {
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
addCameraKeyframeAt(camera.id, 1);
|
||||
@@ -764,9 +1007,10 @@ it("drags the trajectory bar body to move the whole walk later", () => {
|
||||
const { container } = render(<TimelinePanel />);
|
||||
stubTrackRowLayout({}, 500);
|
||||
|
||||
const bar = container.querySelector<HTMLElement>(".timeline-trajectory-lane .timeline-trajectory-bar")!;
|
||||
const bar = container.querySelector<HTMLElement>(".timeline-character-head-lane .timeline-trajectory-bar")!;
|
||||
expect(bar.style.left).toBe("0%");
|
||||
expect(bar.style.width).toBe("60%");
|
||||
// 停留结束默认到时间轴末尾(5s),所以整条条铺满 100%,最后一个点(3s)只是途经点
|
||||
expect(bar.style.width).toBe("100%");
|
||||
|
||||
// 往右拖 100px = +1s:整段刚性平移,两个点一起 +1s
|
||||
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
|
||||
@@ -787,7 +1031,7 @@ it("keeps the trajectory clip inside the timeline when dragged to the end", () =
|
||||
const { container } = render(<TimelinePanel />);
|
||||
stubTrackRowLayout({}, 500);
|
||||
|
||||
const bar = container.querySelector<HTMLElement>(".timeline-trajectory-lane .timeline-trajectory-bar")!;
|
||||
const bar = container.querySelector<HTMLElement>(".timeline-character-head-lane .timeline-trajectory-bar")!;
|
||||
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
|
||||
// 往右拖 1000px 远超时间轴:末点顶到 5s 就不再挪
|
||||
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 1100 });
|
||||
@@ -806,7 +1050,7 @@ it("does not move a trajectory that already fills the whole timeline", () => {
|
||||
const { container } = render(<TimelinePanel />);
|
||||
stubTrackRowLayout({}, 500);
|
||||
|
||||
const bar = container.querySelector<HTMLElement>(".timeline-trajectory-lane .timeline-trajectory-bar")!;
|
||||
const bar = container.querySelector<HTMLElement>(".timeline-character-head-lane .timeline-trajectory-bar")!;
|
||||
expect(bar.style.width).toBe("100%");
|
||||
|
||||
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
|
||||
@@ -869,7 +1113,7 @@ it("selects the trajectory clip when it is grabbed, and clears it on an empty ar
|
||||
const { container } = render(<TimelinePanel />);
|
||||
stubTrackRowLayout({}, 500);
|
||||
|
||||
const bar = container.querySelector<HTMLElement>(".timeline-trajectory-lane .timeline-trajectory-bar")!;
|
||||
const bar = container.querySelector<HTMLElement>(".timeline-character-head-lane .timeline-trajectory-bar")!;
|
||||
expect(bar).not.toHaveClass("is-selected");
|
||||
|
||||
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
|
||||
@@ -883,7 +1127,7 @@ it("selects the trajectory clip when it is grabbed, and clears it on an empty ar
|
||||
clientX: 5,
|
||||
});
|
||||
|
||||
expect(container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")).not.toHaveClass("is-selected");
|
||||
expect(container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")).not.toHaveClass("is-selected");
|
||||
});
|
||||
|
||||
it("selects the clip when one of its own keyframes is grabbed", () => {
|
||||
@@ -899,7 +1143,7 @@ it("selects the clip when one of its own keyframes is grabbed", () => {
|
||||
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");
|
||||
expect(container.querySelector(".timeline-character-head-lane .timeline-trajectory-bar")).toHaveClass("is-selected");
|
||||
});
|
||||
|
||||
it("selects the camera clip both when its body is grabbed and when its keyframe is grabbed", () => {
|
||||
@@ -1126,8 +1370,9 @@ it("nudges the playhead with the ruler keyboard controls", () => {
|
||||
});
|
||||
|
||||
it("positions the playhead on the ruler and labels every track with its object", () => {
|
||||
const objectId = useDirectorStore.getState().project.objects[0].id;
|
||||
const objectName = useDirectorStore.getState().project.objects[0].name;
|
||||
const propObject = addPropObject();
|
||||
const objectId = propObject.id;
|
||||
const objectName = propObject.name;
|
||||
useDirectorStore.getState().selectObject(objectId);
|
||||
const { container } = render(<TimelinePanel />);
|
||||
|
||||
|
||||
@@ -40,12 +40,15 @@ import {
|
||||
moveCameraKeyframeTime,
|
||||
normalizeTimelineDuration,
|
||||
normalizeTimelineFps,
|
||||
scaleCameraKeyframeStart,
|
||||
scaleCameraKeyframeTimes,
|
||||
type CameraKeyframeValue,
|
||||
type KeyframeTransformValue,
|
||||
} from "../schema/timeline";
|
||||
import type { MotionKind } from "../schema/directorProject";
|
||||
import {
|
||||
TRAJECTORY_POINT_MIN_GAP,
|
||||
getTrajectoryClipEndTime,
|
||||
getTrajectoryPointTimes,
|
||||
getTrajectoryStartTime,
|
||||
moveTrajectoryPointTime,
|
||||
@@ -318,6 +321,8 @@ export function TimelinePanel() {
|
||||
const updateTrajectoryPoint = useDirectorStore((state) => state.updateTrajectoryPoint);
|
||||
const setTrajectoryPointTimes = useDirectorStore((state) => state.setTrajectoryPointTimes);
|
||||
const setTrajectoryClipStart = useDirectorStore((state) => state.setTrajectoryClipStart);
|
||||
const setTrajectoryClipTrimStart = useDirectorStore((state) => state.setTrajectoryClipTrimStart);
|
||||
const setTrajectoryClipEnd = useDirectorStore((state) => state.setTrajectoryClipEnd);
|
||||
const timelineTrackOrder = useDirectorStore((state) => state.timelineTrackOrder);
|
||||
const timelineCollapsedTracks = useDirectorStore((state) => state.timelineCollapsedTracks);
|
||||
const setTimelineTrackOrder = useDirectorStore((state) => state.setTimelineTrackOrder);
|
||||
@@ -368,15 +373,18 @@ export function TimelinePanel() {
|
||||
* - point:拖单个关键帧,只改这一帧的时间
|
||||
* - duration:拖条右端把手,整段时长按比例缩放
|
||||
* - clip:拖条本体,整段左右平移(可以脱离 0s 往后放)
|
||||
* - trimStart:拖条左端把手,只挪片段起点(各轨迹点原地不动)
|
||||
*/
|
||||
const timeDragRef = useRef<{
|
||||
pointerId: number;
|
||||
owner: TimelineTimeDragOwner;
|
||||
kind: "point" | "duration" | "clip";
|
||||
kind: "point" | "duration" | "clip" | "trimStart";
|
||||
index: number;
|
||||
startTimes: number[];
|
||||
/** 片段左端在按下时的位置(平移基准) */
|
||||
startEdge: number;
|
||||
/** 片段右端在按下时的位置(轨迹最后一点停留结束,作为拖关键帧的上界) */
|
||||
endEdge: number;
|
||||
startClientX: number;
|
||||
secondsPerPixel: number;
|
||||
} | null>(null);
|
||||
@@ -439,6 +447,30 @@ export function TimelinePanel() {
|
||||
};
|
||||
}, [exportMenuOpen]);
|
||||
|
||||
// 轨迹关键帧没有独立的删除按钮,选中态(.is-selected)就是「按 Delete 会删掉它」的唯一提示,
|
||||
// 所以点到别处(轨道空白、别的轨道、面板其它区域,甚至时间轴之外)就必须取消选中并移走焦点,
|
||||
// 否则选中高亮和焦点框会一直留在那颗关键帧上,看起来像还能按 Delete 删它。
|
||||
useEffect(() => {
|
||||
if (!selectedTrajectoryPoint) return;
|
||||
|
||||
function clearTrajectoryPointOnOutsidePointerDown(event: PointerEvent) {
|
||||
// 点在轨迹关键帧自己身上不算「别处」——那一下是选中它 / 拖它改时间
|
||||
if (event.target instanceof Element && event.target.closest(".timeline-keyframe-trajectory")) return;
|
||||
|
||||
selectTrajectoryPoint(null, null);
|
||||
|
||||
// 焦点还停在那颗关键帧上时一并移走,避免焦点框留在一个已经没选中的元素上
|
||||
const active = document.activeElement;
|
||||
if (active instanceof HTMLElement && active.classList.contains("timeline-keyframe-trajectory")) {
|
||||
active.blur();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", clearTrajectoryPointOnOutsidePointerDown);
|
||||
|
||||
return () => document.removeEventListener("pointerdown", clearTrajectoryPointOnOutsidePointerDown);
|
||||
}, [selectedTrajectoryPoint, selectTrajectoryPoint]);
|
||||
|
||||
// 设置弹窗:Esc 直接关掉(等同取消,草稿值丢弃)
|
||||
useEffect(() => {
|
||||
if (!settingsOpen) return;
|
||||
@@ -484,7 +516,9 @@ export function TimelinePanel() {
|
||||
if (selectionIsManualCamera) {
|
||||
if (!selectedShot || selectedShot.targetMode !== "manual") return;
|
||||
addCameraKeyframe(selectedShot.id, playhead, snapshotShot());
|
||||
} else if (selectedObject) {
|
||||
} else if (selectedObject && selectedObject.kind !== "character") {
|
||||
// 角色由轨迹(trajectory)驱动,不占 transform 关键帧轨道,
|
||||
// 否则同一个角色会同时出现「物体轨道」和「角色轨道」两条。
|
||||
addKeyframe(selectedObject.id, playhead, snapshotObjectTransform());
|
||||
}
|
||||
}
|
||||
@@ -511,6 +545,10 @@ export function TimelinePanel() {
|
||||
}
|
||||
}
|
||||
function handleDeleteSelected() {
|
||||
if (selectedTrajectoryPoint) {
|
||||
removeTrajectoryPoint(selectedTrajectoryPoint.objectId, selectedTrajectoryPoint.pointId);
|
||||
return;
|
||||
}
|
||||
if (!selectedKeyframe) return;
|
||||
if (selectedKeyframe.kind === "object") {
|
||||
removeKeyframe(selectedKeyframe.targetId, selectedKeyframe.keyframeId);
|
||||
@@ -655,8 +693,15 @@ export function TimelinePanel() {
|
||||
|
||||
// 轨道顺序:默认「物体轨道 → 相机运镜轨道 → 人物轨道」,用户可以上移 / 下移调整。
|
||||
// 视觉顺序靠 flex order 实现,DOM 顺序保持稳定(不影响既有查询与无障碍阅读顺序)。
|
||||
// 角色由轨迹驱动,只走「角色轨道」;其 transform 关键帧不单独占一条「物体轨道」,
|
||||
// 否则同一个角色会在时间轴上出现两条。
|
||||
const nonCharacterObjectIds = new Set(
|
||||
project.objects.filter((item) => item.kind !== "character").map((item) => item.id)
|
||||
);
|
||||
const defaultTrackKeys = [
|
||||
...timeline.tracks.map((track) => getTimelineTrackKey("object", track.objectId)),
|
||||
...timeline.tracks
|
||||
.filter((track) => nonCharacterObjectIds.has(track.objectId))
|
||||
.map((track) => getTimelineTrackKey("object", track.objectId)),
|
||||
...timeline.cameraTracks.map((track) => getTimelineTrackKey("camera", track.cameraId)),
|
||||
...characterObjects.map((item) => getTimelineTrackKey("character", item.id)),
|
||||
];
|
||||
@@ -788,38 +833,40 @@ export function TimelinePanel() {
|
||||
return selectedClip === getTimelineClipKey(kind, id);
|
||||
}
|
||||
|
||||
/** 拖整段时长(条右端把手 / 键盘微调):末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定 */
|
||||
/** 拖整段时长(条右端把手 / 键盘微调):
|
||||
* - 运镜:末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定;
|
||||
* - 人物轨迹:末点不再等于时间结束位置,这条把手改的是「停留时长」(角色走完最后一点后
|
||||
* 原地停留到这一刻),所以只写 trajectoryEndTime,不动各轨迹点的时间。 */
|
||||
function scaleClipDuration(
|
||||
owner: TimelineTimeDragOwner,
|
||||
times: number[],
|
||||
startEdge: number,
|
||||
deltaSeconds: number
|
||||
) {
|
||||
const targetEnd = (times[times.length - 1] ?? startEdge) + deltaSeconds;
|
||||
const targetEnd =
|
||||
(owner.kind === "trajectory" ? startEdge : times[times.length - 1] ?? startEdge) + deltaSeconds;
|
||||
|
||||
if (owner.kind === "trajectory") {
|
||||
setTrajectoryPointTimes(
|
||||
owner.objectId,
|
||||
scaleTrajectoryPointTimes(times, targetEnd, duration, startEdge)
|
||||
);
|
||||
setTrajectoryClipEnd(owner.objectId, targetEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeTimes(times, targetEnd, duration));
|
||||
}
|
||||
|
||||
/** 拖单个关键帧:只改它自己的时间(被前后邻居夹住) */
|
||||
/** 拖单个关键帧:只改它自己的时间(被前后邻居夹住;轨迹最后一点还受停留结束时间约束) */
|
||||
function moveClipKeyframe(
|
||||
owner: TimelineTimeDragOwner,
|
||||
times: number[],
|
||||
index: number,
|
||||
startEdge: number,
|
||||
deltaSeconds: number
|
||||
deltaSeconds: number,
|
||||
endEdge = duration
|
||||
) {
|
||||
const targetTime = (times[index] ?? 0) + deltaSeconds;
|
||||
|
||||
if (owner.kind === "trajectory") {
|
||||
const nextTimes = moveTrajectoryPointTime(times, index, targetTime, duration, startEdge);
|
||||
const nextTimes = moveTrajectoryPointTime(times, index, targetTime, duration, startEdge, endEdge);
|
||||
updateTrajectoryPoint(owner.objectId, owner.pointId, { time: nextTimes[index] });
|
||||
return;
|
||||
}
|
||||
@@ -852,6 +899,29 @@ export function TimelinePanel() {
|
||||
setCameraKeyframeTimes(owner.cameraId, times.map((time) => time + shift));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修剪起点(条左端把手),和右端把手对称:
|
||||
* - 人物轨迹:只挪片段起点 —— 角色在这之前原地等待,各轨迹点与停留结束时间留在原地,
|
||||
* 所以「等待多久」变了,走位本身的节奏没变;
|
||||
* - 运镜:末帧固定不动,首帧推到新位置,其余关键帧按比例缩放 —— 和右端把手(首帧固定、
|
||||
* 末帧伸缩)正好反过来,整段运镜的节奏保持不变。
|
||||
*/
|
||||
function trimClipStart(
|
||||
owner: TimelineTimeDragOwner,
|
||||
times: number[],
|
||||
startEdge: number,
|
||||
deltaSeconds: number
|
||||
) {
|
||||
if (owner.kind === "camera") {
|
||||
setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeStart(times, startEdge + deltaSeconds, duration));
|
||||
return;
|
||||
}
|
||||
|
||||
if (owner.kind !== "trajectory") return;
|
||||
|
||||
setTrajectoryClipTrimStart(owner.objectId, startEdge + deltaSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间滑块(人物轨迹条 / 运镜片段条同构):拖动关键帧改「这一帧的时间」,
|
||||
* 拖动条右端把手改「整段时长」(其余帧按比例缩放)。像素 → 秒用轨道宽度换算,宽度只在按下时量一次。
|
||||
@@ -859,10 +929,11 @@ export function TimelinePanel() {
|
||||
function beginTimeDrag(
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
owner: TimelineTimeDragOwner,
|
||||
kind: "point" | "duration" | "clip",
|
||||
kind: "point" | "duration" | "clip" | "trimStart",
|
||||
index: number,
|
||||
times: number[],
|
||||
startEdge = 0
|
||||
startEdge = 0,
|
||||
endEdge = duration
|
||||
) {
|
||||
if (event.button !== 0) return;
|
||||
if (isTrackDragBlockedTarget(event.target)) return;
|
||||
@@ -884,6 +955,7 @@ export function TimelinePanel() {
|
||||
index,
|
||||
startTimes: [...times],
|
||||
startEdge,
|
||||
endEdge,
|
||||
startClientX: event.clientX,
|
||||
secondsPerPixel: duration / laneWidth,
|
||||
};
|
||||
@@ -917,7 +989,12 @@ export function TimelinePanel() {
|
||||
return;
|
||||
}
|
||||
|
||||
moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, deltaTime);
|
||||
if (drag.kind === "trimStart") {
|
||||
trimClipStart(drag.owner, drag.startTimes, drag.startEdge, deltaTime);
|
||||
return;
|
||||
}
|
||||
|
||||
moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, deltaTime, drag.endEdge);
|
||||
}
|
||||
|
||||
function handleTimeDragEnd(event: ReactPointerEvent<HTMLElement>) {
|
||||
@@ -937,14 +1014,15 @@ export function TimelinePanel() {
|
||||
/** 把某个关键帧 / 时长把手 / 条本体接到同一套拖拽手势上 */
|
||||
function getTimeDragProps(
|
||||
owner: TimelineTimeDragOwner,
|
||||
kind: "point" | "duration" | "clip",
|
||||
kind: "point" | "duration" | "clip" | "trimStart",
|
||||
index: number,
|
||||
times: number[],
|
||||
startEdge = 0
|
||||
startEdge = 0,
|
||||
endEdge = duration
|
||||
) {
|
||||
return {
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLElement>) =>
|
||||
beginTimeDrag(event, owner, kind, index, times, startEdge),
|
||||
beginTimeDrag(event, owner, kind, index, times, startEdge, endEdge),
|
||||
onPointerMove: handleTimeDragMove,
|
||||
onPointerUp: handleTimeDragEnd,
|
||||
onPointerCancel: handleTimeDragEnd,
|
||||
@@ -966,6 +1044,21 @@ export function TimelinePanel() {
|
||||
scaleClipDuration(owner, times, startEdge, delta);
|
||||
}
|
||||
|
||||
/** 修剪起点的把手同样支持键盘微调(左右方向键 ±0.1s,按住 Shift ±0.5s) */
|
||||
function handleTrimStartKeyDown(
|
||||
event: ReactKeyboardEvent<HTMLElement>,
|
||||
owner: TimelineTimeDragOwner,
|
||||
times: number[],
|
||||
startEdge: number
|
||||
) {
|
||||
const step = event.shiftKey ? 0.5 : 0.1;
|
||||
const delta = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0;
|
||||
if (delta === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
trimClipStart(owner, times, startEdge, delta);
|
||||
}
|
||||
|
||||
function handleTrackPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
const gesture = dragGestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
@@ -1110,14 +1203,27 @@ export function TimelinePanel() {
|
||||
action();
|
||||
}
|
||||
|
||||
// 焦点在时间轴上时,空格键切换播放 / 暂停(正在播放则暂停,暂停或停止则播放)。
|
||||
// 焦点落在原生可激活控件(按钮 / 输入框等)上时交给控件自身处理,避免与它们的空格行为重复触发。
|
||||
// 焦点在时间轴上时:
|
||||
// - 空格键切换播放 / 暂停(正在播放则暂停,暂停或停止则播放);
|
||||
// - Delete / Backspace 删除选中的轨迹点(角色轨迹点没有独立删除按钮,选中后按删除键移除)。
|
||||
// 焦点落在原生可激活控件(按钮 / 输入框等)上时交给控件自身处理,避免与它们的行为重复触发。
|
||||
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']")) {
|
||||
const insideNativeControl = !!(
|
||||
target && target.closest("button, input, textarea, select, [contenteditable='true']")
|
||||
);
|
||||
|
||||
if (event.key === "Delete" || event.key === "Backspace") {
|
||||
if (insideNativeControl) return;
|
||||
if (selectedTrajectoryPoint) {
|
||||
event.preventDefault();
|
||||
handleDeleteSelected();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== " " && event.key !== "Spacebar") return;
|
||||
if (insideNativeControl) return;
|
||||
event.preventDefault();
|
||||
togglePlaying();
|
||||
}
|
||||
@@ -1387,7 +1493,9 @@ export function TimelinePanel() {
|
||||
{/* tabIndex 让整块轨道区可聚焦:点轨道空白处后焦点会落进面板,
|
||||
这样「焦点在轨道上时空格切换播放/暂停」才能生效(否则焦点在面板外,空格够不到面板级的键盘处理) */}
|
||||
<div className="timeline-grid-body" tabIndex={0} onPointerDown={handleTimelineGridPointerDown}>
|
||||
{timeline.tracks.map((track) => {
|
||||
{timeline.tracks
|
||||
.filter((track) => project.objects.find((o) => o.id === track.objectId)?.kind !== "character")
|
||||
.map((track) => {
|
||||
const obj = project.objects.find((o) => o.id === track.objectId);
|
||||
const KindIcon = (obj ? OBJECT_KIND_ICONS[obj.kind] : undefined) ?? Box;
|
||||
const trackName = obj?.name ?? track.objectId;
|
||||
@@ -1432,17 +1540,6 @@ export function TimelinePanel() {
|
||||
onClick={() => handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)}
|
||||
>
|
||||
<span className="timeline-keyframe-dot" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-keyframe-delete"
|
||||
aria-label={`删除关键帧 ${formatTime(kf.time)}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeKeyframe(track.objectId, kf.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -1467,10 +1564,15 @@ export function TimelinePanel() {
|
||||
const clipLeftPercent = duration > 0 ? (clipStart / duration) * 100 : 0;
|
||||
const clipWidthPercent = duration > 0 ? (clipDuration / duration) * 100 : 0;
|
||||
const clipOwner: TimelineTimeDragOwner = { kind: "camera", cameraId: track.cameraId };
|
||||
// 运镜关键帧连线:在「机位」子轨道上把各关键帧用折线连起来(视图坐标 0~100 对应整条时间轴宽度,
|
||||
// 50 = 轨道垂直中点),直观呈现运镜过程
|
||||
const cameraConnectorPoints = cameraTimes
|
||||
.map((t) => `${(duration > 0 ? (t / duration) * 100 : 0).toFixed(2)},50`)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`timeline-track timeline-track-camera${collapsed ? " is-collapsed" : ""}${
|
||||
className={`timeline-camera-track${collapsed ? " is-collapsed" : ""}${
|
||||
draggingTrackKey === trackKey ? " is-dragging" : ""
|
||||
}`}
|
||||
key={track.cameraId}
|
||||
@@ -1478,93 +1580,127 @@ export function TimelinePanel() {
|
||||
style={{ order: getTrackOrder(trackKey) }}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="timeline-track-label" title={`${trackName} · 拖动整行可调整轨道顺序`}>
|
||||
{renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
|
||||
<Video aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
|
||||
<span className="timeline-track-name">{trackName}</span>
|
||||
<span className="timeline-track-count">{track.keyframes.length}</span>
|
||||
<div className="timeline-track timeline-track-camera">
|
||||
<div className="timeline-track-label" title={`${trackName} · 拖动整行可调整轨道顺序`}>
|
||||
{renderCollapseToggle(trackKey, trackName, "运镜轨道", collapsed)}
|
||||
<Video aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
|
||||
<span className="timeline-track-name">{trackName}</span>
|
||||
<span className="timeline-track-count">{track.keyframes.length}</span>
|
||||
</div>
|
||||
{/* 运镜轨道头部车道:展开时放「运镜片段条」,折叠时放一行摘要(与角色轨道头部一致) */}
|
||||
<div className="timeline-track-lane">
|
||||
{collapsed ? (
|
||||
<span className="timeline-track-summary">{track.keyframes.length} 个运镜关键帧</span>
|
||||
) : cameraTimes.length >= 2 && duration > 0 ? (
|
||||
<div
|
||||
className={`timeline-trajectory-bar timeline-clip-bar-camera${
|
||||
isClipSelected("camera", track.cameraId) ? " is-selected" : ""
|
||||
}`}
|
||||
style={{ left: `${clipLeftPercent}%`, width: `${clipWidthPercent}%` }}
|
||||
title={`运镜片段 ${formatTime(clipStart)} → ${formatTime(
|
||||
clipEnd
|
||||
)}(时长 ${formatTime(clipDuration)}):拖动条本体整段平移,拖动左右两端把手分别修剪起点与终点`}
|
||||
{...getTimeDragProps(clipOwner, "clip", 0, cameraTimes, clipStart)}
|
||||
>
|
||||
<span className="timeline-trajectory-bar-label">{formatTime(clipDuration)}</span>
|
||||
{/* 左端把手 = 修剪起点:末帧不动,首帧推后,各关键帧按比例缩放 */}
|
||||
<span
|
||||
className="timeline-trajectory-trim is-pre"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={`${trackName} 运镜起点`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Number(
|
||||
Math.max(
|
||||
clipEnd - CAMERA_KEYFRAME_MIN_GAP * Math.max(cameraTimes.length - 1, 0),
|
||||
0
|
||||
).toFixed(2)
|
||||
)}
|
||||
aria-valuenow={Number(clipStart.toFixed(2))}
|
||||
aria-valuetext={formatTime(clipStart)}
|
||||
title={`拖动修剪运镜起点(${formatTime(
|
||||
clipStart
|
||||
)} 开始这段运镜,末帧不动、其余关键帧按比例缩放),或用左右方向键微调`}
|
||||
onKeyDown={(event) => handleTrimStartKeyDown(event, clipOwner, cameraTimes, clipStart)}
|
||||
{...getTimeDragProps(clipOwner, "trimStart", 0, cameraTimes, clipStart)}
|
||||
/>
|
||||
{/* 右端把手 = 修剪终点:首帧不动,末帧伸缩 */}
|
||||
<span
|
||||
className="timeline-trajectory-trim is-post"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={`${trackName} 运镜时长`}
|
||||
aria-valuemin={Math.min(clipStart + CAMERA_KEYFRAME_MIN_GAP, clipEnd)}
|
||||
aria-valuemax={Math.max(duration, clipEnd)}
|
||||
aria-valuenow={Number(clipEnd.toFixed(2))}
|
||||
aria-valuetext={`${formatTime(clipDuration)},结束于 ${formatTime(clipEnd)}`}
|
||||
title="拖动整体缩放这段运镜的时长(第一个运镜关键帧固定不动),或用左右方向键微调"
|
||||
onKeyDown={(event) =>
|
||||
handleTimeHandleKeyDown(event, clipOwner, cameraTimes, clipStart)
|
||||
}
|
||||
{...getTimeDragProps(
|
||||
clipOwner,
|
||||
"duration",
|
||||
cameraTimes.length - 1,
|
||||
cameraTimes,
|
||||
clipStart
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="timeline-track-lane">
|
||||
{collapsed ? (
|
||||
<span className="timeline-track-summary">{track.keyframes.length} 个运镜关键帧</span>
|
||||
) : (
|
||||
<>
|
||||
{/* 运镜片段条:拖条本体可整段左右平移(前面那段时间相机保持首帧机位),
|
||||
拖右端把手整体缩放这段运镜的时长;只有一个关键帧时没有「运镜过程」,不画条。 */}
|
||||
{cameraTimes.length >= 2 && duration > 0 ? (
|
||||
<div
|
||||
className={`timeline-trajectory-bar timeline-clip-bar-camera${
|
||||
isClipSelected("camera", track.cameraId) ? " is-selected" : ""
|
||||
}`}
|
||||
style={{ left: `${clipLeftPercent}%`, width: `${clipWidthPercent}%` }}
|
||||
title={`运镜片段 ${formatTime(clipStart)} → ${formatTime(
|
||||
clipEnd
|
||||
)}(时长 ${formatTime(clipDuration)}):拖动条本体整段平移,拖动右端把手改时长`}
|
||||
{...getTimeDragProps(clipOwner, "clip", 0, cameraTimes, clipStart)}
|
||||
>
|
||||
<span className="timeline-trajectory-bar-label">{formatTime(clipDuration)}</span>
|
||||
<span
|
||||
className="timeline-trajectory-handle"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={`${trackName} 运镜时长`}
|
||||
aria-valuemin={Math.min(clipStart + CAMERA_KEYFRAME_MIN_GAP, clipEnd)}
|
||||
aria-valuemax={Math.max(duration, clipEnd)}
|
||||
aria-valuenow={Number(clipEnd.toFixed(2))}
|
||||
aria-valuetext={`${formatTime(clipDuration)},结束于 ${formatTime(clipEnd)}`}
|
||||
title="拖动整体缩放这段运镜的时长(第一个运镜关键帧固定不动),或用左右方向键微调"
|
||||
onKeyDown={(event) =>
|
||||
handleTimeHandleKeyDown(event, clipOwner, cameraTimes, clipStart)
|
||||
}
|
||||
{...getTimeDragProps(
|
||||
clipOwner,
|
||||
"duration",
|
||||
cameraTimes.length - 1,
|
||||
cameraTimes,
|
||||
clipStart
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{track.keyframes.map((kf, index) => {
|
||||
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
|
||||
const isSelected =
|
||||
selectedKeyframe?.kind === "camera" &&
|
||||
selectedKeyframe.targetId === track.cameraId &&
|
||||
selectedKeyframe.keyframeId === kf.id;
|
||||
return (
|
||||
<div
|
||||
key={kf.id}
|
||||
className={`timeline-keyframe timeline-keyframe-camera${
|
||||
isSelected ? " is-selected" : ""
|
||||
}`}
|
||||
style={{ left: `${leftPercent}%` }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
|
||||
title={`运镜关键帧 · ${formatTime(kf.time)}(左右拖动调整时间)`}
|
||||
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
|
||||
{...getTimeDragProps(clipOwner, "point", index, cameraTimes)}
|
||||
>
|
||||
<span className="timeline-keyframe-dot" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-keyframe-delete"
|
||||
aria-label={`删除运镜关键帧 ${formatTime(kf.time)}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeCameraKeyframe(track.cameraId, kf.id);
|
||||
}}
|
||||
{collapsed ? null : (
|
||||
<>
|
||||
{/* 机位子轨道:运镜关键帧单独一条轨道,并用折线把相邻关键帧连起来呈现运镜过程 */}
|
||||
<div
|
||||
className="timeline-subtrack"
|
||||
title="机位关键帧:每个运镜关键帧,可单独左右拖动改时间,选中后点 × 或按 Delete 删除。折线连接相邻关键帧呈现运镜过程"
|
||||
>
|
||||
<div className="timeline-subtrack-label">关键帧</div>
|
||||
<div className="timeline-track-lane timeline-camera-keyframe-lane">
|
||||
{cameraTimes.length > 0 && duration > 0 ? (
|
||||
<>
|
||||
{/* 折线连接各运镜关键帧,呈现运镜过程 */}
|
||||
<svg
|
||||
className="timeline-trajectory-connector"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<polyline points={cameraConnectorPoints} />
|
||||
</svg>
|
||||
{track.keyframes.map((kf, index) => {
|
||||
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
|
||||
const isSelected =
|
||||
selectedKeyframe?.kind === "camera" &&
|
||||
selectedKeyframe.targetId === track.cameraId &&
|
||||
selectedKeyframe.keyframeId === kf.id;
|
||||
return (
|
||||
<div
|
||||
key={kf.id}
|
||||
className={`timeline-keyframe timeline-keyframe-camera${
|
||||
isSelected ? " is-selected" : ""
|
||||
}`}
|
||||
style={{ left: `${leftPercent}%` }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
|
||||
title={`运镜关键帧 · ${formatTime(kf.time)}(左右拖动调整时间)`}
|
||||
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
|
||||
{...getTimeDragProps(clipOwner, "point", index, cameraTimes)}
|
||||
>
|
||||
<span className="timeline-keyframe-dot" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1580,11 +1716,18 @@ export function TimelinePanel() {
|
||||
const trackKey = getTimelineTrackKey("character", character.id);
|
||||
const collapsed = timelineCollapsedTracks.includes(trackKey);
|
||||
const dragProps = getTrackDragProps(trackKey);
|
||||
// 轨迹片段的起点(拖条本体可整段平移,0 表示从 0s 就走)与每个轨迹点的关键帧时间
|
||||
// 轨迹片段的起点(拖条本体可整段平移,0 表示从 0s 就走)、每个轨迹点的关键帧时间,
|
||||
// 以及片段结束时间(角色走完最后一点后原地停留到这一刻,即「停留时长」)。
|
||||
// 默认结束时间 = 时间轴末尾,所以最后一个点落在时间结束处;一旦设置 trajectoryEndTime,
|
||||
// 最后一个点只是路径中途的途经点,条右边缘到结束时间之间都是停留段。
|
||||
const trajectoryStart = getTrajectoryStartTime(character, duration);
|
||||
const trajectoryTimes = getTrajectoryPointTimes(trajectoryPoints, duration, trajectoryStart);
|
||||
const trajectoryEndTime =
|
||||
trajectoryTimes.length > 0 ? trajectoryTimes[trajectoryTimes.length - 1] : trajectoryStart;
|
||||
const trajectoryEndTime = getTrajectoryClipEndTime(character, duration, trajectoryStart);
|
||||
// 轨迹点连线:在「轨迹」轨道上把各轨迹点用折线连起来(视图坐标 0~100 对应整条时间轴宽度,
|
||||
// 50 = 轨道垂直中点),直观呈现走位路径(最后一个点只是途经点,后面是停留段)
|
||||
const trajectoryConnectorPoints = [trajectoryStart, ...trajectoryTimes]
|
||||
.map((t) => `${(duration > 0 ? (t / duration) * 100 : 0).toFixed(2)},50`)
|
||||
.join(" ");
|
||||
const trajectoryLeftPercent = duration > 0 ? (trajectoryStart / duration) * 100 : 0;
|
||||
const trajectoryWidthPercent =
|
||||
duration > 0 ? Math.min(100, ((trajectoryEndTime - trajectoryStart) / duration) * 100) : 0;
|
||||
@@ -1613,12 +1756,71 @@ export function TimelinePanel() {
|
||||
<span className="timeline-track-name">{character.name}</span>
|
||||
<span className="timeline-track-count">{trajectoryPoints.length}</span>
|
||||
</div>
|
||||
{/* 折叠后抬头行里给一句摘要,避免整条轨道变成空白 */}
|
||||
{/* 分组抬头行:展开时显示轨迹片段条,折叠时显示一行摘要 */}
|
||||
<div className="timeline-track-lane timeline-character-head-lane">
|
||||
{collapsed ? (
|
||||
<span className="timeline-track-summary">
|
||||
{motionLabel} · {trajectoryPoints.length} 个轨迹点
|
||||
</span>
|
||||
) : trajectoryTimes.length > 0 && duration > 0 ? (
|
||||
<div
|
||||
className={`timeline-trajectory-bar${
|
||||
isClipSelected("trajectory", character.id) ? " is-selected" : ""
|
||||
}`}
|
||||
style={{ left: `${trajectoryLeftPercent}%`, width: `${trajectoryWidthPercent}%` }}
|
||||
title={`轨迹片段 ${formatTime(trajectoryStart)} → ${formatTime(
|
||||
trajectoryEndTime
|
||||
)}(时长 ${formatTime(
|
||||
trajectoryEndTime - trajectoryStart
|
||||
)},结尾停留到 ${formatTime(
|
||||
trajectoryEndTime
|
||||
)}):拖动条本体整段平移,拖动左右两端把手分别修剪起点与停留终点`}
|
||||
{...getTimeDragProps(trajectoryOwner, "clip", 0, trajectoryTimes, trajectoryStart)}
|
||||
>
|
||||
<span className="timeline-trajectory-bar-label">{formatTime(trajectoryEndTime)}</span>
|
||||
{/* 左端把手 = 修剪起点:只挪片段起点(角色在此之前原地等待),轨迹点留在原地 */}
|
||||
<span
|
||||
className="timeline-trajectory-trim is-pre"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={`${character.name} 轨迹起点`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Number(
|
||||
Math.max((trajectoryTimes[0] ?? 0) - TRAJECTORY_POINT_MIN_GAP, 0).toFixed(2)
|
||||
)}
|
||||
aria-valuenow={Number(trajectoryStart.toFixed(2))}
|
||||
aria-valuetext={formatTime(trajectoryStart)}
|
||||
title={`拖动修剪轨迹起点(${formatTime(
|
||||
trajectoryStart
|
||||
)} 之前角色原地等待),或用左右方向键微调`}
|
||||
onKeyDown={(event) =>
|
||||
handleTrimStartKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryStart)
|
||||
}
|
||||
{...getTimeDragProps(trajectoryOwner, "trimStart", 0, trajectoryTimes, trajectoryStart)}
|
||||
/>
|
||||
{/* 右端把手 = 修剪停留终点:角色走完最后一点后停留到这一刻 */}
|
||||
<span
|
||||
className="timeline-trajectory-trim is-post"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={`${character.name} 轨迹停留时长`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration}
|
||||
aria-valuenow={Number(trajectoryEndTime.toFixed(2))}
|
||||
aria-valuetext={formatTime(trajectoryEndTime)}
|
||||
title="拖动调整轨迹停留时长(角色走完最后一点后原地停留到这一刻),或用左右方向键微调"
|
||||
onKeyDown={(event) =>
|
||||
handleTimeHandleKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryEndTime)
|
||||
}
|
||||
{...getTimeDragProps(
|
||||
trajectoryOwner,
|
||||
"duration",
|
||||
trajectoryTimes.length - 1,
|
||||
trajectoryTimes,
|
||||
trajectoryEndTime
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1636,57 +1838,25 @@ export function TimelinePanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 人物轨迹:一条「轨迹条」= 角色从起点走到最后一个轨迹点所需的时长。
|
||||
条本体可左右拖动整段平移(脱离 0s,前面那段时间角色原地等待),
|
||||
右端把手拖动改整段时长,条上的关键帧可单独拖动改时间 */}
|
||||
{/* 人物轨迹轨道:一条「轨迹」子轨道,显示各轨迹点关键帧(小菱形),
|
||||
并用折线把点连起来呈现走位路径。点可单独左右拖动改时间,选中后按 Delete 删除 */}
|
||||
<div
|
||||
className="timeline-subtrack"
|
||||
title="人物轨迹:拖动条本体可整段左右平移(起点之前角色原地等待);拖动右端把手调整整段时长;拖动关键帧调整该点时间"
|
||||
title="人物轨迹:每个轨迹点一条关键帧,可单独左右拖动改时间,选中后按 Delete 删除"
|
||||
>
|
||||
<div className="timeline-subtrack-label">轨迹</div>
|
||||
<div className="timeline-track-lane timeline-trajectory-lane">
|
||||
{trajectoryTimes.length > 0 && duration > 0 ? (
|
||||
<>
|
||||
<div
|
||||
className={`timeline-trajectory-bar${
|
||||
isClipSelected("trajectory", character.id) ? " is-selected" : ""
|
||||
}`}
|
||||
style={{ left: `${trajectoryLeftPercent}%`, width: `${trajectoryWidthPercent}%` }}
|
||||
title={`轨迹片段 ${formatTime(trajectoryStart)} → ${formatTime(
|
||||
trajectoryEndTime
|
||||
)}(时长 ${formatTime(trajectoryEndTime - trajectoryStart)}):拖动条本体整段平移,拖动右端把手改时长`}
|
||||
{...getTimeDragProps(
|
||||
trajectoryOwner,
|
||||
"clip",
|
||||
0,
|
||||
trajectoryTimes,
|
||||
trajectoryStart
|
||||
)}
|
||||
{/* 折线连接各轨迹点,呈现走位路径(最后一个点只是途经点) */}
|
||||
<svg
|
||||
className="timeline-trajectory-connector"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="timeline-trajectory-bar-label">{formatTime(trajectoryEndTime)}</span>
|
||||
<span
|
||||
className="timeline-trajectory-handle"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={`${character.name} 轨迹时长`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration}
|
||||
aria-valuenow={Number(trajectoryEndTime.toFixed(2))}
|
||||
aria-valuetext={formatTime(trajectoryEndTime)}
|
||||
title="拖动调整轨迹时长(所有轨迹点按比例缩放),或用左右方向键微调"
|
||||
onKeyDown={(event) =>
|
||||
handleTimeHandleKeyDown(event, trajectoryOwner, trajectoryTimes, trajectoryStart)
|
||||
}
|
||||
{...getTimeDragProps(
|
||||
trajectoryOwner,
|
||||
"duration",
|
||||
trajectoryTimes.length - 1,
|
||||
trajectoryTimes,
|
||||
trajectoryStart
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<polyline points={trajectoryConnectorPoints} />
|
||||
</svg>
|
||||
{trajectoryPoints.map((point, index) => {
|
||||
const pointTime = trajectoryTimes[index] ?? 0;
|
||||
const leftPercent = duration === 0 ? 0 : (pointTime / duration) * 100;
|
||||
@@ -1704,27 +1874,20 @@ export function TimelinePanel() {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`轨迹关键帧 ${formatTime(pointTime)}`}
|
||||
title={`第 ${index + 1} 个轨迹点 · ${formatTime(pointTime)}(左右拖动调整时间)`}
|
||||
title={`第 ${index + 1} 个轨迹点 · ${formatTime(
|
||||
pointTime
|
||||
)}(左右拖动调整时间,选中后按 Delete 删除)`}
|
||||
onClick={() => handleSelectTrajectoryKeyframe(character.id, point.id, pointTime)}
|
||||
{...getTimeDragProps(
|
||||
{ kind: "trajectory", objectId: character.id, pointId: point.id },
|
||||
"point",
|
||||
index,
|
||||
trajectoryTimes
|
||||
trajectoryTimes,
|
||||
trajectoryStart,
|
||||
trajectoryEndTime
|
||||
)}
|
||||
>
|
||||
<span className="timeline-keyframe-dot" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-keyframe-delete"
|
||||
aria-label={`删除轨迹关键帧 ${formatTime(pointTime)}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeTrajectoryPoint(character.id, point.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -232,6 +232,12 @@ export interface DirectorObject {
|
||||
* 大于 0 表示角色先在起点原地等待,到这一刻才沿轨迹移动(时间轴上可拖动整条轨迹条平移)。
|
||||
*/
|
||||
trajectoryStartTime?: number;
|
||||
/**
|
||||
* 人物轨迹片段的结束时间(秒):角色走完最后一个轨迹点后,原地停留到这一刻。
|
||||
* 缺省 / 为 0 时沿用旧语义(结束时间 = 最后一个轨迹点的时间,最后一个点即时间结束处);
|
||||
* 设置后,最后一个轨迹点只是路径中途的一个途经点,条右边缘到这一刻之间都是「停留」段。
|
||||
*/
|
||||
trajectoryEndTime?: number;
|
||||
}
|
||||
|
||||
export interface DirectorCameraCapture {
|
||||
|
||||
@@ -362,6 +362,39 @@ export function scaleCameraKeyframeTimes(times: number[], newEnd: number, durati
|
||||
return scaled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖动运镜片段左端把手(修剪起点):与右端把手完全对称 —— 末帧固定不动,
|
||||
* 首帧落到 newStart,其余关键帧按同一比例缩放,各帧之间的相对节奏不变。
|
||||
* newStart 夹在「0 ~ 末帧 - 最小跨度」之间。
|
||||
*/
|
||||
export function scaleCameraKeyframeStart(times: number[], newStart: number, duration: number): number[] {
|
||||
const count = times.length;
|
||||
if (count < 2) return times;
|
||||
|
||||
const end = times[count - 1];
|
||||
const start = times[0];
|
||||
const currentSpan = end - start;
|
||||
if (!Number.isFinite(currentSpan) || currentSpan <= 0) return times;
|
||||
|
||||
// 缩放上界:末帧不动,首帧最多贴到「末帧 - 最小跨度」处,保证各帧还能依次排开
|
||||
const minSpan = CAMERA_KEYFRAME_MIN_GAP * (count - 1);
|
||||
const upper = Math.max(Math.min(end - minSpan, duration), 0);
|
||||
const nextStart = Math.min(Math.max(newStart, 0), upper);
|
||||
if (Math.abs(nextStart - start) < 1e-6) return times;
|
||||
|
||||
const ratio = (end - nextStart) / currentSpan;
|
||||
const scaled = times.map((value, index) => {
|
||||
if (index === count - 1) return end;
|
||||
const next = end - (end - value) * ratio;
|
||||
// 这一帧之后还剩 count-1-index 帧,各自要留够最小间隔
|
||||
const ceiling = end - CAMERA_KEYFRAME_MIN_GAP * (count - 1 - index);
|
||||
return Math.max(Math.min(next, ceiling), nextStart);
|
||||
});
|
||||
scaled[0] = nextStart;
|
||||
|
||||
return scaled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性覆盖整条运镜轨道的所有关键帧时间(按索引对应,最后统一按时间重排)。
|
||||
* 拖动把手 / 拖动关键帧都走这里,避免逐个更新时反复排序导致索引错位。
|
||||
|
||||
@@ -93,7 +93,8 @@ export function moveTrajectoryPointTime(
|
||||
index: number,
|
||||
time: number,
|
||||
duration: number,
|
||||
startTime = 0
|
||||
startTime = 0,
|
||||
endTime = duration
|
||||
): number[] {
|
||||
if (index < 0 || index >= times.length) return times;
|
||||
|
||||
@@ -103,7 +104,7 @@ export function moveTrajectoryPointTime(
|
||||
? Math.max(Number.isFinite(startTime) ? startTime : 0, 0) + TRAJECTORY_POINT_MIN_GAP
|
||||
: times[index - 1] + TRAJECTORY_POINT_MIN_GAP;
|
||||
const upper = isLast
|
||||
? Math.max(duration, lower)
|
||||
? Math.max(Math.min(Number.isFinite(endTime) ? endTime : duration, duration), lower)
|
||||
: times[index + 1] - TRAJECTORY_POINT_MIN_GAP;
|
||||
const next = [...times];
|
||||
next[index] = Math.min(Math.max(time, lower), Math.max(lower, upper));
|
||||
@@ -111,6 +112,27 @@ export function moveTrajectoryPointTime(
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 人物轨迹片段的结束时间(秒):角色走完最后一个轨迹点后原地停留到这一刻。
|
||||
* 缺省(未设置 trajectoryEndTime)时取时间轴总时长(即最后一个点落在时间轴末尾,无停留段);
|
||||
* 一旦显式设置,返回该值,并夹在「最后一个轨迹点的时间 ~ 时间轴末尾」之间,
|
||||
* 保证最后一个点永远落在停留段起点之前(不再等于时间结束位置)。
|
||||
*/
|
||||
export function getTrajectoryClipEndTime(
|
||||
owner: DirectorObject,
|
||||
duration = Number.POSITIVE_INFINITY,
|
||||
startTime = 0
|
||||
): number {
|
||||
const points = owner.trajectoryPoints ?? [];
|
||||
const times = getTrajectoryPointTimes(points, duration, startTime);
|
||||
const lastPointTime = times.length > 0 ? times[times.length - 1] : startTime;
|
||||
const safeDuration = Number.isFinite(duration) ? duration : 0;
|
||||
const stored = owner.trajectoryEndTime;
|
||||
const rawEnd = typeof stored === "number" && Number.isFinite(stored) ? stored : safeDuration;
|
||||
|
||||
return Math.min(Math.max(rawEnd, lastPointTime), Math.max(safeDuration, lastPointTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖动整段轨迹的时长(条右端把手):末点落到 newEnd,其余点按同一比例缩放,
|
||||
* 保持各点之间的相对节奏,片段起点固定不动。newEnd 夹在「起点 + 最小跨度 ~ 时间轴末尾」之间。
|
||||
|
||||
@@ -53,7 +53,12 @@ import {
|
||||
type CameraKeyframeValue,
|
||||
type Timeline,
|
||||
} from "../schema/timeline";
|
||||
import { getTrajectoryStartTime, shiftTrajectoryClip } from "../schema/trajectoryMotion";
|
||||
import {
|
||||
TRAJECTORY_POINT_MIN_GAP,
|
||||
getTrajectoryPointTimes,
|
||||
getTrajectoryStartTime,
|
||||
shiftTrajectoryClip,
|
||||
} from "../schema/trajectoryMotion";
|
||||
|
||||
export type TransformMode = "translate" | "rotate" | "scale";
|
||||
|
||||
@@ -159,8 +164,13 @@ export interface DirectorActions {
|
||||
setTrajectoryPointTimes: (objectId: string, times: number[]) => void;
|
||||
/** 整体平移人物轨迹片段(拖轨迹条本体):片段起点 + 所有轨迹点时间同步平移 */
|
||||
setTrajectoryClipStart: (objectId: string, startTime: number) => void;
|
||||
/** 修剪人物轨迹片段起点(拖轨迹条左端把手):只挪片段起点,轨迹点与停留结束时间都不动 */
|
||||
setTrajectoryClipTrimStart: (objectId: string, startTime: number) => void;
|
||||
/** 设置人物轨迹片段的结束时间(停留时长):角色走完最后一点后原地停留到这一刻,拖条右端把手时写入 */
|
||||
setTrajectoryClipEnd: (objectId: string, endTime: number) => void;
|
||||
removeTrajectoryPoint: (objectId: string, pointId: string) => void;
|
||||
selectTrajectoryPoint: (objectId: string, pointId: string) => void;
|
||||
/** 选中某个轨迹点;传 null 表示取消选中(点到轨道空白处 / 面板其它地方时清掉轨迹关键帧的选中态) */
|
||||
selectTrajectoryPoint: (objectId: string | null, pointId: string | null) => void;
|
||||
updateScene: (patch: Partial<SceneSettings>) => void;
|
||||
removePanoramaAsset: () => void;
|
||||
removeImportedAsset: (assetId: string) => void;
|
||||
@@ -1763,21 +1773,75 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
|
||||
// 片段刚性平移:起点夹在时间轴内,所有轨迹点跟着走同样的量。
|
||||
// 点的时间在这里落成显式值,避免默认推导把片段「压缩」而不是「平移」。
|
||||
const currentStart = getTrajectoryStartTime(item, state.project.timeline.duration);
|
||||
const { startTime: nextStart, times } = shiftTrajectoryClip(
|
||||
points,
|
||||
state.project.timeline.duration,
|
||||
getTrajectoryStartTime(item, state.project.timeline.duration),
|
||||
currentStart,
|
||||
startTime
|
||||
);
|
||||
const delta = nextStart - currentStart;
|
||||
// 停留结束时间(trajectoryEndTime)同样随片段刚性平移,保持与轨迹点的相对关系
|
||||
const nextEndTime =
|
||||
typeof item.trajectoryEndTime === "number"
|
||||
? Math.min(Math.max(item.trajectoryEndTime + delta, 0), state.project.timeline.duration)
|
||||
: item.trajectoryEndTime;
|
||||
|
||||
return {
|
||||
...item,
|
||||
trajectoryStartTime: nextStart,
|
||||
trajectoryEndTime: nextEndTime,
|
||||
trajectoryPoints: points.map((point, index) => ({ ...point, time: times[index] })),
|
||||
};
|
||||
}),
|
||||
},
|
||||
})),
|
||||
setTrajectoryClipTrimStart: (objectId, startTime) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||||
const points = item.trajectoryPoints ?? [];
|
||||
if (points.length === 0) return item;
|
||||
|
||||
const duration = state.project.timeline.duration;
|
||||
const currentStart = getTrajectoryStartTime(item, duration);
|
||||
const times = getTrajectoryPointTimes(points, duration, currentStart);
|
||||
const firstPointTime = times[0] ?? 0;
|
||||
// 起点最多挪到「第一个轨迹点再往前一个最小间隔」处 —— 不能越过第一个点,
|
||||
// 否则那一列点就落在片段起点之前,和「起点之前原地等待」的语义冲突。
|
||||
const upper = Math.max(firstPointTime - TRAJECTORY_POINT_MIN_GAP, 0);
|
||||
const nextStart = Math.min(Math.max(startTime, 0), upper);
|
||||
|
||||
return {
|
||||
...item,
|
||||
trajectoryStartTime: nextStart,
|
||||
// 把点的时间落成显式值:这些点不该跟着起点走,否则就退化成整段平移而不是修剪起点。
|
||||
// (没有显式 time 的点会按「起点 → 时间轴末尾」重新推导,改起点会连点一起拖走。)
|
||||
trajectoryPoints: points.map((point, index) => ({ ...point, time: times[index] })),
|
||||
};
|
||||
}),
|
||||
},
|
||||
})),
|
||||
setTrajectoryClipEnd: (objectId, endTime) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
objects: updateObjectById(state.project.objects, objectId, (item) => {
|
||||
const points = item.trajectoryPoints ?? [];
|
||||
if (points.length === 0) return item;
|
||||
|
||||
const duration = state.project.timeline.duration;
|
||||
const times = getTrajectoryPointTimes(points, duration, getTrajectoryStartTime(item, duration));
|
||||
const lastPointTime = times.length > 0 ? times[times.length - 1] : 0;
|
||||
const nextEnd = Math.min(Math.max(endTime, lastPointTime), duration);
|
||||
|
||||
return { ...item, trajectoryEndTime: nextEnd };
|
||||
}),
|
||||
},
|
||||
})),
|
||||
removeTrajectoryPoint: (objectId, pointId) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
@@ -1795,6 +1859,11 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
})),
|
||||
selectTrajectoryPoint: (objectId, pointId) =>
|
||||
commitUiMutation((state) => {
|
||||
// 取消选中:只清掉「轨迹点」这一层,不动角色本身的选择,避免点一下空白就把对象也取消选中
|
||||
if (!objectId || !pointId) {
|
||||
return state.selectedTrajectoryPoint ? { ...state, selectedTrajectoryPoint: null } : state;
|
||||
}
|
||||
|
||||
const target = state.project.objects.find((item) => item.id === objectId);
|
||||
if (!target?.trajectoryPoints?.some((point) => point.id === pointId)) return state;
|
||||
|
||||
|
||||
@@ -1408,6 +1408,11 @@ textarea:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 长标签(如「截图/录制时显示网格」)独占一整行,避免与相邻开关项重叠 */
|
||||
.scene-switch-row .inspector-toggle-row.toggle-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.inspector-action-button {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
@@ -3177,26 +3182,75 @@ textarea:focus-visible {
|
||||
background: rgb(var(--surface-hover-rgb) / 0.72);
|
||||
}
|
||||
|
||||
.camera-capture-viewer {
|
||||
.camera-capture-viewer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(0 0 0 / 0.9);
|
||||
background: rgb(0 0 0 / 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 可拖动的浮动对话框:默认居中,拖动标题栏时通过 transform 偏移移动 */
|
||||
.camera-capture-viewer {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 100001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(90vw, 920px);
|
||||
height: min(85vh, 760px);
|
||||
min-width: 360px;
|
||||
min-height: 300px;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgb(var(--surface-rgb) / 0.98);
|
||||
box-shadow: 0 28px 70px rgb(0 0 0 / 0.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-capture-viewer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 0.08);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.camera-capture-viewer-header.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.camera-capture-viewer-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: rgb(var(--text-rgb));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.camera-capture-viewer-counter {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: rgb(var(--text-dim-rgb));
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.camera-capture-viewer-toolbar {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.camera-capture-viewer-tool {
|
||||
@@ -3240,17 +3294,56 @@ textarea:focus-visible {
|
||||
background: rgb(255 255 255 / 0.2);
|
||||
}
|
||||
|
||||
.camera-capture-viewer-tool:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
}
|
||||
|
||||
/* 右下角拖动调节尺寸的手柄,悬浮在标题栏 / 工具栏之外,不会遮挡图片 */
|
||||
.camera-capture-viewer-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
z-index: 5;
|
||||
cursor: nwse-resize;
|
||||
border-bottom-right-radius: 12px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
transparent 0,
|
||||
transparent 50%,
|
||||
rgb(255 255 255 / 0.28) 50%,
|
||||
rgb(255 255 255 / 0.28) 62%,
|
||||
transparent 62%,
|
||||
transparent 74%,
|
||||
rgb(255 255 255 / 0.28) 74%,
|
||||
rgb(255 255 255 / 0.28) 86%,
|
||||
transparent 86%
|
||||
);
|
||||
}
|
||||
|
||||
.camera-capture-viewer-resize.is-resizing {
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.camera-capture-viewer-stage {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
background: #000000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-capture-viewer-image {
|
||||
display: block;
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
border-radius: 8px;
|
||||
@@ -4127,15 +4220,18 @@ textarea:focus-visible {
|
||||
}
|
||||
/* ---- 轨道拖拽排序:整行都是拖拽单元 ---- */
|
||||
.timeline-track,
|
||||
.timeline-character-track {
|
||||
.timeline-character-track,
|
||||
.timeline-camera-track {
|
||||
cursor: grab;
|
||||
}
|
||||
.timeline-track:active,
|
||||
.timeline-character-track:active {
|
||||
.timeline-character-track:active,
|
||||
.timeline-camera-track:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.timeline-track.is-dragging,
|
||||
.timeline-character-track.is-dragging {
|
||||
.timeline-character-track.is-dragging,
|
||||
.timeline-camera-track.is-dragging {
|
||||
opacity: 0.42;
|
||||
}
|
||||
/* 拖拽落点线:独立的一条线,只靠 transform 上下移动。
|
||||
@@ -4179,7 +4275,8 @@ body.is-timeline-dragging * {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
/* 折叠后的轨道:车道内容换成一句摘要,网格线淡化表示「这里被折起来了」 */
|
||||
.timeline-track.is-collapsed .timeline-track-lane {
|
||||
.timeline-track.is-collapsed .timeline-track-lane,
|
||||
.timeline-camera-track.is-collapsed .timeline-track-lane {
|
||||
background-image: none;
|
||||
}
|
||||
.timeline-track-summary {
|
||||
@@ -4214,6 +4311,10 @@ body.is-timeline-dragging * {
|
||||
.timeline-character-track {
|
||||
background: rgb(var(--accent-rgb) / 0.05);
|
||||
}
|
||||
/* 机位(运镜)轨道:和角色轨道一样,整组(头部 + 机位子轨道)用一个容器包起来,便于整体拖拽排序、折叠 */
|
||||
.timeline-camera-track {
|
||||
background: rgb(var(--accent-rgb) / 0.05);
|
||||
}
|
||||
.timeline-subtrack {
|
||||
display: grid;
|
||||
grid-template-columns: var(--timeline-gutter-width) minmax(0, 1fr);
|
||||
@@ -4234,16 +4335,26 @@ body.is-timeline-dragging * {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 分组抬头行:展开时轨道留空只作分组抬头,折叠时显示一行摘要 */
|
||||
/* 分组抬头行:展开时轨道留空只作分组抬头,折叠时显示一行摘要。
|
||||
头部车道兼作「轨迹条」的容器,需要成为绝对定位的基准,且去掉左右内边距,
|
||||
否则轨迹条 / 关键帧按百分比定位会相对网格错开 8px */
|
||||
.timeline-character-head-lane {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
padding: 0;
|
||||
border-bottom-color: rgb(var(--border-rgb) / 0.22);
|
||||
}
|
||||
/* 机位子轨道的车道:与轨迹子轨道同理,承载折线 + 关键帧,铺满整条、与网格对齐 */
|
||||
.timeline-camera-keyframe-lane {
|
||||
padding: 0;
|
||||
}
|
||||
.timeline-character-track.is-collapsed {
|
||||
background: rgb(var(--accent-rgb) / 0.03);
|
||||
}
|
||||
.timeline-camera-track.is-collapsed {
|
||||
background: rgb(var(--accent-rgb) / 0.03);
|
||||
}
|
||||
.timeline-motion-lane {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4297,26 +4408,37 @@ body.is-timeline-dragging * {
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.timeline-trajectory-handle {
|
||||
/* 轨迹片段条的左右修剪把手:贴在条内侧两端,整条高度可抓。
|
||||
平时只露一条淡淡的分隔色,选中 / hover 时才加重成明确的把手。 */
|
||||
.timeline-trajectory-trim {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -5px;
|
||||
bottom: -3px;
|
||||
width: 9px;
|
||||
border-radius: 3px;
|
||||
background: rgb(var(--accent-rgb));
|
||||
box-shadow: 0 0 0 1px rgb(var(--panel-rgb) / 0.85);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: ew-resize;
|
||||
touch-action: none;
|
||||
background: rgb(var(--accent-rgb) / 0.45);
|
||||
transition: background-color 0.14s ease;
|
||||
}
|
||||
.timeline-trajectory-handle:hover,
|
||||
.timeline-trajectory-handle:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(var(--panel-rgb) / 0.9),
|
||||
0 0 0 4px rgb(var(--accent-rgb) / 0.25);
|
||||
.timeline-trajectory-trim.is-pre {
|
||||
left: 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
.timeline-trajectory-trim.is-post {
|
||||
right: 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.timeline-trajectory-trim:hover,
|
||||
.timeline-trajectory-trim:focus-visible {
|
||||
background: rgb(var(--accent-rgb));
|
||||
outline: none;
|
||||
}
|
||||
/* 运镜片段条:和人物轨迹条同一套「片段条 + 右端把手」交互(首帧固定、拖把手缩放整段时长),
|
||||
/* 选中态:两端把手站出来,明确「左右都能拖」 */
|
||||
.timeline-trajectory-bar.is-selected .timeline-trajectory-trim {
|
||||
background: rgb(var(--accent-rgb));
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--panel-rgb) / 0.55);
|
||||
}
|
||||
/* 运镜片段条:和人物轨迹条同一套「片段条 + 左右两端把手」交互(拖左端修剪起点、拖右端修剪终点),
|
||||
只是区间是「第一个运镜关键帧 → 最后一个关键帧」,配色换成运镜绿以区分 */
|
||||
.timeline-clip-bar-camera {
|
||||
border-color: rgb(18 184 134 / 0.42);
|
||||
@@ -4325,14 +4447,33 @@ body.is-timeline-dragging * {
|
||||
.timeline-clip-bar-camera .timeline-trajectory-bar-label {
|
||||
color: #12b886;
|
||||
}
|
||||
.timeline-clip-bar-camera .timeline-trajectory-handle {
|
||||
/* 运镜条的把手跟着运镜绿走,hover / 聚焦 / 选中时加重 */
|
||||
.timeline-clip-bar-camera .timeline-trajectory-trim {
|
||||
background: rgb(18 184 134 / 0.45);
|
||||
}
|
||||
.timeline-clip-bar-camera .timeline-trajectory-trim:hover,
|
||||
.timeline-clip-bar-camera .timeline-trajectory-trim:focus-visible,
|
||||
.timeline-clip-bar-camera.is-selected .timeline-trajectory-trim {
|
||||
background: #12b886;
|
||||
}
|
||||
.timeline-clip-bar-camera .timeline-trajectory-handle:hover,
|
||||
.timeline-clip-bar-camera .timeline-trajectory-handle:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(var(--panel-rgb) / 0.9),
|
||||
0 0 0 4px rgb(18 184 134 / 0.25);
|
||||
/* 轨迹条内的连线:把各轨迹点用直线连起来,直观呈现走位路径(viewBox 0~100 映射条内百分比,
|
||||
preserveAspectRatio=none 让它随条拉伸铺满;non-scaling-stroke 保证线宽不随缩放变形) */
|
||||
.timeline-trajectory-connector {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
.timeline-trajectory-connector polyline {
|
||||
fill: none;
|
||||
stroke: rgb(var(--accent-rgb));
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* 片段选中态:底色 + 边框 + 外描边一起加重(轨迹条走强调色,运镜条走它自己的绿)。
|
||||
必须排在「未选中」规则之后:同优先级下靠源序决定,选中态才盖得住。 */
|
||||
@@ -4386,7 +4527,7 @@ body.is-timeline-dragging * {
|
||||
}
|
||||
.timeline-keyframe-dot {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
inset: 5px;
|
||||
border-radius: 2px;
|
||||
background: #4f8ef7;
|
||||
box-shadow: 0 0 0 1px rgb(var(--panel-rgb) / 0.75);
|
||||
@@ -4420,29 +4561,6 @@ body.is-timeline-dragging * {
|
||||
0 0 0 1px rgb(var(--panel-rgb) / 0.9),
|
||||
0 0 0 3px rgb(242 169 0 / 0.28);
|
||||
}
|
||||
.timeline-keyframe-delete {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: #e0524d;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 12px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.timeline-keyframe:hover .timeline-keyframe-delete,
|
||||
.timeline-keyframe.is-selected .timeline-keyframe-delete,
|
||||
.timeline-keyframe:focus-visible .timeline-keyframe-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ===== 全屏画布 + 悬浮卡片 =====
|
||||
画布铺满整屏;顶部工具条、左右侧栏、底部时间轴一律脱离文档流悬浮在画布之上。
|
||||
|
||||
@@ -127,13 +127,19 @@ it("matches the provided right inspector layout dimensions and field styling", (
|
||||
expect(css).toMatch(/\.right-sidebar\s*\.right-inspector\s*button\.camera-capture-action\s*\{[\s\S]*?color:\s*rgb\(255 255 255 \/ 0\.82\);/);
|
||||
expect(css).toMatch(/\.right-sidebar\s*\.right-inspector\s*button\.camera-capture-action:hover,\s*[\r\n]+\s*\.right-sidebar\s*\.right-inspector\s*button\.camera-capture-action:focus-visible\s*\{[\s\S]*?color:\s*rgb\(255 255 255\);/);
|
||||
expect(css).toMatch(/\.camera-capture-name\s*\{[\s\S]*?font-size:\s*12px;[\s\S]*?line-height:\s*17px;[\s\S]*?white-space:\s*nowrap;[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer\s*\{[\s\S]*?position:\s*fixed;[\s\S]*?inset:\s*0;[\s\S]*?z-index:\s*100000;[\s\S]*?display:\s*flex;[\s\S]*?align-items:\s*center;[\s\S]*?justify-content:\s*center;[\s\S]*?background:\s*rgb\(0 0 0 \/ 0\.9\);[\s\S]*?backdrop-filter:\s*blur\(4px\);/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-toolbar\s*\{[\s\S]*?position:\s*absolute;[\s\S]*?top:\s*16px;[\s\S]*?right:\s*16px;[\s\S]*?display:\s*flex;[\s\S]*?gap:\s*8px;[\s\S]*?z-index:\s*10;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-backdrop\s*\{[\s\S]*?position:\s*fixed;[\s\S]*?inset:\s*0;[\s\S]*?z-index:\s*100000;[\s\S]*?background:\s*rgb\(0 0 0 \/ 0\.6\);[\s\S]*?backdrop-filter:\s*blur\(4px\);/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer\s*\{[\s\S]*?position:\s*fixed;[\s\S]*?z-index:\s*100001;[\s\S]*?display:\s*flex;[\s\S]*?flex-direction:\s*column;[\s\S]*?width:\s*min\(90vw,\s*920px\);[\s\S]*?min-width:\s*360px;[\s\S]*?min-height:\s*300px;[\s\S]*?border-radius:\s*12px;[\s\S]*?background:\s*rgb\(var\(--surface-rgb\) \/ 0\.98\);/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-header\s*\{[\s\S]*?display:\s*flex;[\s\S]*?align-items:\s*center;[\s\S]*?justify-content:\s*space-between;[\s\S]*?cursor:\s*grab;[\s\S]*?user-select:\s*none;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-header\.is-dragging\s*\{[\s\S]*?cursor:\s*grabbing;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-title\s*\{[\s\S]*?color:\s*rgb\(var\(--text-rgb\)\);[\s\S]*?white-space:\s*nowrap;[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-counter\s*\{[\s\S]*?color:\s*rgb\(var\(--text-dim-rgb\)\);[\s\S]*?font-variant-numeric:\s*tabular-nums;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-resize\s*\{[\s\S]*?position:\s*absolute;[\s\S]*?right:\s*0;[\s\S]*?bottom:\s*0;[\s\S]*?width:\s*18px;[\s\S]*?height:\s*18px;[\s\S]*?z-index:\s*5;[\s\S]*?cursor:\s*nwse-resize;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-toolbar\s*\{[\s\S]*?display:\s*flex;[\s\S]*?align-items:\s*center;[\s\S]*?gap:\s*8px;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-tool\s*\{[\s\S]*?width:\s*40px;[\s\S]*?height:\s*40px;[\s\S]*?border:\s*1px solid rgb\(255 255 255 \/ 0\.1\);[\s\S]*?border-radius:\s*8px;[\s\S]*?background:\s*rgb\(255 255 255 \/ 0\.1\);/);
|
||||
expect(css).toMatch(/\.right-inspector\s*button\.camera-capture-viewer-tool\s*\{[\s\S]*?color:\s*rgb\(255 255 255 \/ 0\.9\);/);
|
||||
expect(css).toMatch(/\.right-inspector\s*button\.camera-capture-viewer-tool:hover,\s*[\r\n]+\s*\.right-inspector\s*button\.camera-capture-viewer-tool:focus-visible\s*\{[\s\S]*?color:\s*rgb\(255 255 255\);/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-stage\s*\{[\s\S]*?display:\s*grid;[\s\S]*?place-items:\s*center;[\s\S]*?width:\s*100%;[\s\S]*?height:\s*100%;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-image\s*\{[\s\S]*?max-width:\s*80vw;[\s\S]*?max-height:\s*80vh;[\s\S]*?user-select:\s*none;[\s\S]*?border-radius:\s*8px;[\s\S]*?object-fit:\s*contain;[\s\S]*?transition:\s*transform 0\.2s ease;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-stage\s*\{[\s\S]*?display:\s*grid;[\s\S]*?place-items:\s*center;[\s\S]*?flex:\s*1 1 auto;[\s\S]*?min-height:\s*0;[\s\S]*?width:\s*100%;[\s\S]*?background:\s*#000000;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-image\s*\{[\s\S]*?max-width:\s*100%;[\s\S]*?max-height:\s*100%;[\s\S]*?user-select:\s*none;[\s\S]*?border-radius:\s*8px;[\s\S]*?object-fit:\s*contain;[\s\S]*?transition:\s*transform 0\.2s ease;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-image\.is-zoomed\s*\{[\s\S]*?cursor:\s*grab;/);
|
||||
expect(css).toMatch(/\.camera-capture-viewer-image\.is-dragging\s*\{[\s\S]*?cursor:\s*grabbing;[\s\S]*?transition:\s*none;/);
|
||||
expect(css).not.toContain(".panorama-preview-card");
|
||||
|
||||
Reference in New Issue
Block a user