feat(timeline): 支持拖拽调整时长与运镜/轨迹关键帧时间

This commit is contained in:
2026-09-17 17:30:22 +08:00
parent bab92c05b8
commit 8d67f17a84
21 changed files with 2556 additions and 194 deletions

View File

@@ -1,4 +1,5 @@
import {
applyExternalViewSnapshot,
applySnapshotToControls,
DEFAULT_DIRECTOR_VIEW_SNAPSHOT,
getViewportGizmoHitButtonStyle,
@@ -43,6 +44,27 @@ it("flushes leftover orbit inertia before writing the reset view back", () => {
expect(camera.position.toArray()).toEqual(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.position);
});
it("writes an external view change on the spot instead of deferring it to the next render", () => {
// 机位视角下没有 OrbitControls:写不进去,交给切回导演视角时的副作用补写
expect(applyExternalViewSnapshot(null, DEFAULT_DIRECTOR_VIEW_SNAPSHOT)).toBe(false);
const dampingStateDuringUpdate: boolean[] = [];
const controls = {
enableDamping: true,
target: new Vector3(9, 9, 9),
object: new PerspectiveCamera(),
update: () => dampingStateDuringUpdate.push(controls.enableDamping),
};
expect(applyExternalViewSnapshot(controls, DEFAULT_DIRECTOR_VIEW_SNAPSHOT)).toBe(true);
// 当场就把残余惯性结算掉并落到目标视角(惯性滑行中点重置视角不会再被推走)
expect(dampingStateDuringUpdate).toEqual([false, false]);
expect(controls.target.toArray()).toEqual(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.target);
expect(controls.object.position.toArray()).toEqual(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.position);
expect(controls.enableDamping).toBe(true);
});
it("keeps the viewport grid visible when a panorama background is active", () => {
expect(shouldRenderViewportGrid(true, false)).toBe(true);
});

View File

@@ -166,6 +166,20 @@ function applySnapshotToCamera(camera: ThreePerspectiveCamera, snapshot: CameraS
camera.updateMatrixWorld();
}
/**
* 外部改视角(重置视角 / 坐标控件切轴)时「当场」结算残余惯性并把视角写回相机与 OrbitControls。
* 返回是否真的写回去了 —— 机位视角下没有 OrbitControls,只能等切回导演视角时由副作用补写。
*/
export function applyExternalViewSnapshot(
controls: { enableDamping: boolean; target: Vector3; update: () => void; object: unknown } | null,
snapshot: CameraShotSnapshot
): boolean {
if (!controls) return false;
applySnapshotToControls(controls, controls.object as ThreePerspectiveCamera, snapshot);
return true;
}
/**
* 把视角快照写回相机 + OrbitControls(重置视角、坐标控件切轴、切回导演视角都走这里)。
*
@@ -747,13 +761,28 @@ export function DirectorCanvas() {
return viewportCameraSnapshotRef.current;
}
/** 记录/广播导演视角快照;source 决定 DirectorViewCameraSync 要不要把它写回相机 */
/**
* 记录/广播导演视角快照;source 决定这次变化是谁产生的。
*
* 外部改视角(重置视角 / 坐标控件切轴)时**当场写回**相机 + OrbitControls,
* 不能只丢给 DirectorViewCameraSync 的 layout effect。惯性滑行时有两道闸门会把它吞掉:
* 1. 快照可能因为「和当前视角足够接近」被 React 合并掉 → 状态没变 → 副作用不会重跑;
* 2. OrbitControls 每帧都抛 change,把来源标成 "controls",effect 里那道
* 「用户自己在操作就别写回」的闸门会顺手拦掉这次外部请求。
* 两者都表现为「点了重置视角没有反应、镜头继续滑」。当场结算残余惯性最稳;
* 随后 effect 再跑到也只是同值重写(幂等)。
*/
function updateDirectorViewSnapshot(
snapshot: CameraShotSnapshot,
source: ViewportSnapshotSource = "external"
) {
viewportCameraSnapshotRef.current = snapshot;
viewportSnapshotSourceRef.current = source;
if (source === "external") {
applyExternalViewSnapshot(controlsRef.current, snapshot);
}
setDirectorViewSnapshot((currentSnapshot) =>
areCameraSnapshotsClose(currentSnapshot, snapshot) ? currentSnapshot : snapshot
);

View File

@@ -30,7 +30,7 @@ import {
VIEWPORT_CAMERA_VISUAL_SCALE,
} from "../schema/cameraGeometry";
import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels";
import { evaluateTrajectoryMotionAtTime, getTrajectoryPointTime } from "../schema/trajectoryMotion";
import { evaluateTrajectoryMotionAtTime, getTrajectoryPointTimes } from "../schema/trajectoryMotion";
import type { TransformMode } from "../store/directorStore";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
@@ -920,7 +920,7 @@ function TrajectoryPath({
if (pointIndex < 0) return;
setPlaying(false);
setPlayhead(getTrajectoryPointTime(pointIndex, points.length, playbackDuration));
setPlayhead(getTrajectoryPointTimes(points, playbackDuration)[pointIndex] ?? 0);
}
/**