refactor(timeline): 解耦机位片段与运镜关键帧并新增过渡片段编辑
- 新增 TransitionClipEditor 与 CameraClipEditor,支持编辑过渡类型、时长及机位片段起止 - 抽出可复用的 TimelineClipDragHandle,支持拖拽、修剪、吸附与方向键微调 - 机位轨道改用显式活跃片段(CameraActiveClip),关键帧仅作只读占位推导 - 画布视锥线支持机位自定义颜色 - 视频导出时传入过渡片段、机位与场景参数以保持预览一致 - 更新 TimelinePanel 相关测试以覆盖新模型与交互
This commit is contained in:
@@ -52,6 +52,7 @@ function getFrustumMetrics({
|
||||
const lines = getViewportCameraFrustumLines({
|
||||
id: "cam_1",
|
||||
name: "机位01",
|
||||
color: "#0CA678",
|
||||
fov,
|
||||
transform: {
|
||||
position: [0, 2.2, 9],
|
||||
@@ -203,6 +204,7 @@ it("starts the viewfinder lines from the centered front lens frame", () => {
|
||||
const frustumLines = getViewportCameraFrustumLines({
|
||||
id: "cam_1",
|
||||
name: "机位01",
|
||||
color: "#0CA678",
|
||||
fov: 50,
|
||||
transform: {
|
||||
position: [0, 2.2, 9],
|
||||
|
||||
@@ -231,6 +231,7 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
|
||||
const displayCameraId =
|
||||
view === "camera"
|
||||
? getActiveCameraIdAtTime(
|
||||
directorStore.project.timeline.transitionClips,
|
||||
directorStore.project.timeline.cameraTracks,
|
||||
directorStore.project.activeCameraId,
|
||||
time
|
||||
@@ -239,7 +240,10 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise<void> {
|
||||
// 有运镜轨道时两边共用同一份插值(预览 / 导出一致);返回 false 表示没覆盖相机
|
||||
const appliedCameraTrack = applyActiveCameraViewAtTime({
|
||||
camera: frameCamera,
|
||||
transitionClips: directorStore.project.timeline.transitionClips,
|
||||
cameraTracks: directorStore.project.timeline.cameraTracks,
|
||||
cameras: directorStore.project.cameras,
|
||||
projectScene: directorStore.project.scene,
|
||||
activeCameraId: displayCameraId,
|
||||
playhead: time,
|
||||
viewMode: view,
|
||||
|
||||
@@ -937,23 +937,37 @@ function getCameraKeyframeTimes(cameraId: string) {
|
||||
return track.keyframes.map((keyframe) => keyframe.time);
|
||||
}
|
||||
|
||||
it("renders a camera clip bar spanning the first and last camera keyframes", () => {
|
||||
/** 读取某机位的「活跃片段(clips)」起止(新模型:与运镜关键帧解耦,需显式添加) */
|
||||
function getCameraClipTimes(cameraId: string) {
|
||||
const track = useDirectorStore
|
||||
.getState()
|
||||
.project.timeline.cameraTracks.find((item) => item.cameraId === cameraId)!;
|
||||
return track.clips.map((clip) => ({ start: clip.start, end: clip.end }));
|
||||
}
|
||||
|
||||
it("renders a placeholder camera clip bar spanning the first and last camera keyframes", () => {
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
addCameraKeyframeAt(camera.id, 1);
|
||||
addCameraKeyframeAt(camera.id, 4);
|
||||
|
||||
const { container } = render(<TimelinePanel />);
|
||||
|
||||
const bar = container.querySelector<HTMLElement>(".timeline-clip-bar-camera")!;
|
||||
// 仅打了运镜关键帧、尚未显式添加片段:自动推导一个只读占位活跃窗口(clips 兜底同样在求值层生效)
|
||||
const bar = container.querySelector<HTMLElement>(".timeline-clip-bar-camera.is-placeholder")!;
|
||||
// 1s → 4s 的运镜:条从 20% 开始、宽 60%(5s 时间轴)
|
||||
expect(bar.style.left).toBe("20%");
|
||||
expect(bar.style.width).toBe("60%");
|
||||
// 条上写的是「这段运镜的时长」
|
||||
expect(bar.querySelector(".timeline-trajectory-bar-label")?.textContent).toBe("3.0s");
|
||||
// 占位条只读:没有可拖拽的 slider 把手
|
||||
expect(bar.querySelectorAll(".timeline-trajectory-trim")).toHaveLength(0);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 运镜时长` });
|
||||
expect(handle).toHaveAttribute("aria-valuenow", "4");
|
||||
expect(handle).toHaveAttribute("aria-valuemax", "5");
|
||||
// 点击占位条即转为可编辑的机位活跃片段
|
||||
fireEvent.click(bar);
|
||||
const clips = getCameraClipTimes(camera.id);
|
||||
expect(clips).toHaveLength(1);
|
||||
expect(clips[0].start).toBeCloseTo(1, 5);
|
||||
expect(clips[0].end).toBeCloseTo(4, 5);
|
||||
});
|
||||
|
||||
it("does not draw a camera clip bar for a single camera keyframe", () => {
|
||||
@@ -962,36 +976,37 @@ it("does not draw a camera clip bar for a single camera keyframe", () => {
|
||||
|
||||
const { container } = render(<TimelinePanel />);
|
||||
|
||||
// 单关键帧无法构成区间,既不画占位条也不画片段
|
||||
expect(container.querySelector(".timeline-clip-bar-camera")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "运镜关键帧 1.0s" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drags the camera clip handle to rescale the whole camera move", () => {
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
addCameraKeyframeAt(camera.id, 1);
|
||||
addCameraKeyframeAt(camera.id, 4);
|
||||
useDirectorStore.getState().addCameraClip(camera.id, 1, 4);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
// 5s 时间轴对应 500px 轨道 → 1px = 0.01s
|
||||
stubTrackRowLayout({}, 500);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 运镜时长` });
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 片段 终点` });
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 480 });
|
||||
// 拖条时同样冻结全站毛玻璃
|
||||
expect(document.body).toHaveClass("is-timeline-dragging");
|
||||
|
||||
// 往左拖 100px → 运镜从 1s→4s 缩短到 1s→3s,首帧固定不动
|
||||
// 往左拖 100px → 片段从 1s→4s 缩短到 1s→3s,起点固定不动
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 380 });
|
||||
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 380 });
|
||||
|
||||
const times = getCameraKeyframeTimes(camera.id);
|
||||
expect(times[0]).toBe(1);
|
||||
expect(times[1]).toBeCloseTo(3, 5);
|
||||
const clip = getCameraClipTimes(camera.id)[0];
|
||||
expect(clip.start).toBeCloseTo(1, 5);
|
||||
expect(clip.end).toBeCloseTo(3, 5);
|
||||
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", () => {
|
||||
it("drags the camera clip left handle to trim the start without moving the camera keyframes", () => {
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
useDirectorStore.getState().addCameraClip(camera.id, 1, 4);
|
||||
addCameraKeyframeAt(camera.id, 1);
|
||||
addCameraKeyframeAt(camera.id, 4);
|
||||
|
||||
@@ -1002,66 +1017,66 @@ it("drags the camera clip left handle to trim the start while the last keyframe
|
||||
// 左右两端各有一条修剪把手
|
||||
expect(bar.querySelectorAll(".timeline-trajectory-trim")).toHaveLength(2);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 运镜起点` });
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 片段 起点` });
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 100 });
|
||||
// 往右拖 100px = +1s → 运镜推迟到 2s 才开始,末帧仍停在 4s
|
||||
// 往右拖 100px = +1s → 片段推迟到 2s 才开始,运镜关键帧仍停在 1s / 4s(已解耦)
|
||||
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 200 });
|
||||
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 200 });
|
||||
|
||||
const clip = getCameraClipTimes(camera.id)[0];
|
||||
expect(clip.start).toBeCloseTo(2, 5);
|
||||
expect(clip.end).toBeCloseTo(4, 5);
|
||||
const times = getCameraKeyframeTimes(camera.id);
|
||||
expect(times[0]).toBeCloseTo(2, 5);
|
||||
expect(times[0]).toBeCloseTo(1, 5);
|
||||
expect(times[1]).toBeCloseTo(4, 5);
|
||||
});
|
||||
|
||||
it("clamps the camera trim-start handle so it cannot overrun the last keyframe", () => {
|
||||
it("clamps the camera trim-start handle so it cannot overrun the clip end", () => {
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
addCameraKeyframeAt(camera.id, 1);
|
||||
addCameraKeyframeAt(camera.id, 4);
|
||||
useDirectorStore.getState().addCameraClip(camera.id, 1, 4);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
stubTrackRowLayout({}, 500);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 运镜起点` });
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 片段 起点` });
|
||||
fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 100 });
|
||||
// 一路拖到末帧之后也不越过去:最多到 3.95s(末帧前留一个最小间隔)
|
||||
// 一路拖到时间轴末尾也不越过去:最多到 end - 最小片段长(4 - 0.1 = 3.9s)
|
||||
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);
|
||||
const clip = getCameraClipTimes(camera.id)[0];
|
||||
expect(clip.start).toBeCloseTo(3.9, 5);
|
||||
expect(clip.end).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);
|
||||
useDirectorStore.getState().addCameraClip(camera.id, 1, 4);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 运镜起点` });
|
||||
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);
|
||||
const clip = getCameraClipTimes(camera.id)[0];
|
||||
// 起点 1s → 1.1s,终点不动
|
||||
expect(clip.start).toBeCloseTo(1.1, 5);
|
||||
expect(clip.end).toBeCloseTo(4, 5);
|
||||
});
|
||||
|
||||
it("nudges the camera clip duration with the arrow keys", () => {
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
addCameraKeyframeAt(camera.id, 1);
|
||||
addCameraKeyframeAt(camera.id, 4);
|
||||
useDirectorStore.getState().addCameraClip(camera.id, 1, 4);
|
||||
|
||||
render(<TimelinePanel />);
|
||||
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 运镜时长` });
|
||||
const handle = screen.getByRole("slider", { name: `${camera.name} 片段 终点` });
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
|
||||
// 末帧 4s → 3.9s,中间没有别的帧,首帧仍在 1s
|
||||
const times = getCameraKeyframeTimes(camera.id);
|
||||
expect(times[0]).toBe(1);
|
||||
expect(times[1]).toBeCloseTo(3.9, 5);
|
||||
// 终点 4s → 3.9s,起点仍在 1s
|
||||
const clip = getCameraClipTimes(camera.id)[0];
|
||||
expect(clip.start).toBeCloseTo(1, 5);
|
||||
expect(clip.end).toBeCloseTo(3.9, 5);
|
||||
});
|
||||
|
||||
it("drags a single camera keyframe to change only its own time", () => {
|
||||
@@ -1170,8 +1185,7 @@ it("does not move a trajectory that already fills the whole timeline", () => {
|
||||
|
||||
it("drags the camera clip bar to move the whole camera move later", () => {
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
addCameraKeyframeAt(camera.id, 1);
|
||||
addCameraKeyframeAt(camera.id, 3);
|
||||
useDirectorStore.getState().addCameraClip(camera.id, 1, 3);
|
||||
|
||||
const { container } = render(<TimelinePanel />);
|
||||
stubTrackRowLayout({}, 500);
|
||||
@@ -1180,20 +1194,19 @@ it("drags the camera clip bar to move the whole camera move later", () => {
|
||||
expect(bar.style.left).toBe("20%");
|
||||
expect(bar.style.width).toBe("40%");
|
||||
|
||||
// 往右拖 100px = +1s:两个关键帧一起 +1s
|
||||
// 往右拖 100px = +1s:整个片段一起 +1s
|
||||
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 });
|
||||
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 200 });
|
||||
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 200 });
|
||||
|
||||
const times = getCameraKeyframeTimes(camera.id);
|
||||
expect(times[0]).toBeCloseTo(2, 5);
|
||||
expect(times[1]).toBeCloseTo(4, 5);
|
||||
const clip = getCameraClipTimes(camera.id)[0];
|
||||
expect(clip.start).toBeCloseTo(2, 5);
|
||||
expect(clip.end).toBeCloseTo(4, 5);
|
||||
});
|
||||
|
||||
it("keeps the camera clip inside the timeline when dragged past the end", () => {
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
addCameraKeyframeAt(camera.id, 1);
|
||||
addCameraKeyframeAt(camera.id, 4);
|
||||
useDirectorStore.getState().addCameraClip(camera.id, 1, 4);
|
||||
|
||||
const { container } = render(<TimelinePanel />);
|
||||
stubTrackRowLayout({}, 500);
|
||||
@@ -1203,10 +1216,10 @@ it("keeps the camera clip inside the timeline when dragged past the end", () =>
|
||||
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 1100 });
|
||||
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 1100 });
|
||||
|
||||
// 末帧顶到 5s 为止,首帧跟着挪到 2s(整段长度不变)
|
||||
const times = getCameraKeyframeTimes(camera.id);
|
||||
expect(times[0]).toBeCloseTo(2, 5);
|
||||
expect(times[1]).toBeCloseTo(5, 5);
|
||||
// 终点顶到 5s 为止,起点跟着挪到 2s(整段长度不变)
|
||||
const clip = getCameraClipTimes(camera.id)[0];
|
||||
expect(clip.start).toBeCloseTo(2, 5);
|
||||
expect(clip.end).toBeCloseTo(5, 5);
|
||||
});
|
||||
|
||||
it("selects the trajectory clip when it is grabbed, and clears it on an empty area click", () => {
|
||||
@@ -1251,8 +1264,9 @@ it("selects the clip when one of its own keyframes is grabbed", () => {
|
||||
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", () => {
|
||||
it("selects the camera clip when its body is grabbed, and selects only the keyframe when a keyframe is grabbed", async () => {
|
||||
const camera = useDirectorStore.getState().project.cameras[0];
|
||||
useDirectorStore.getState().addCameraClip(camera.id, 1, 3);
|
||||
addCameraKeyframeAt(camera.id, 1);
|
||||
addCameraKeyframeAt(camera.id, 3);
|
||||
|
||||
@@ -1266,7 +1280,7 @@ it("selects the camera clip both when its body is grabbed and when its keyframe
|
||||
expect(bar).toHaveClass("is-selected");
|
||||
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 100 });
|
||||
|
||||
// 点空白处取消选中后,再按住关键帧同样能把片段带亮
|
||||
// 点空白处取消选中
|
||||
fireEvent.pointerDown(container.querySelector<HTMLElement>(".timeline-grid-body")!, {
|
||||
button: 0,
|
||||
pointerId: 2,
|
||||
@@ -1274,11 +1288,13 @@ it("selects the camera clip both when its body is grabbed and when its keyframe
|
||||
});
|
||||
expect(container.querySelector(".timeline-clip-bar-camera")).not.toHaveClass("is-selected");
|
||||
|
||||
// 关键帧与片段已解耦:点关键帧只选中关键帧,不会把片段带亮
|
||||
const user = userEvent.setup();
|
||||
const keyframe = screen.getByRole("button", { name: "运镜关键帧 1.0s" });
|
||||
fireEvent.pointerDown(keyframe, { button: 0, pointerId: 3, clientX: 100 });
|
||||
fireEvent.pointerUp(keyframe, { pointerId: 3, clientX: 100 });
|
||||
await user.click(keyframe);
|
||||
|
||||
expect(container.querySelector(".timeline-clip-bar-camera")).toHaveClass("is-selected");
|
||||
expect(container.querySelector(".timeline-clip-bar-camera")).not.toHaveClass("is-selected");
|
||||
expect(keyframe).toHaveClass("is-selected");
|
||||
});
|
||||
|
||||
it("jumps to the first and last frame from the transport controls", () => {
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
MAX_CAMERA_TRANSITION_DURATION,
|
||||
DEFAULT_CAMERA_TRANSITION_DURATION,
|
||||
normalizeCameraTransitionDuration,
|
||||
getCameraClipSpan,
|
||||
moveCameraKeyframeTime,
|
||||
normalizeTimelineDuration,
|
||||
normalizeTimelineFps,
|
||||
@@ -50,12 +49,15 @@ import {
|
||||
scaleCameraKeyframeTimes,
|
||||
EASING_KINDS,
|
||||
EASING_LABELS,
|
||||
getCameraCutSegments,
|
||||
TRANSITION_CLIP_MIN_DURATION,
|
||||
CAMERA_CLIP_MIN_DURATION,
|
||||
type CameraKeyframeValue,
|
||||
type CameraTransition,
|
||||
type CameraTransitionType,
|
||||
type EasingKind,
|
||||
type KeyframeTransformValue,
|
||||
type TransitionClip,
|
||||
type CameraActiveClip,
|
||||
} from "../schema/timeline";
|
||||
import type { DirectorCameraShot, MotionClip, MotionKind, ExportQuality, ExportCodecPreference } from "../schema/directorProject";
|
||||
import { EXPORT_RESOLUTION_OPTIONS, EXPORT_QUALITY_OPTIONS, EXPORT_CODEC_OPTIONS } from "../schema/directorProject";
|
||||
@@ -100,6 +102,15 @@ function formatTime(t: number) {
|
||||
return `${t.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
/** 把机位存的十六进制色(如 #0CA678)转成 CSS rgb() 可用的 "r g b" 三元组,便于带透明度复用。 */
|
||||
function hexToRgbTriple(hex: string): string {
|
||||
const value = hex.replace("#", "");
|
||||
const r = parseInt(value.slice(0, 2), 16);
|
||||
const g = parseInt(value.slice(2, 4), 16);
|
||||
const b = parseInt(value.slice(4, 6), 16);
|
||||
return `${r} ${g} ${b}`;
|
||||
}
|
||||
|
||||
/** 读数里的时间:整秒省掉小数位(1s 而不是 1.0s),非整秒保留一位 */
|
||||
function formatReadoutTime(t: number) {
|
||||
const safeTime = Number.isFinite(t) ? t : 0;
|
||||
@@ -320,6 +331,386 @@ function TimelineTrackCollapseToggle({
|
||||
);
|
||||
}
|
||||
|
||||
/** 可拖拽 / 可修剪两端的时间片段条(运镜过渡片段、机位活跃片段共用)。移动条本体整体平移,拖左右把手修剪起止。 */
|
||||
/** 把候选时刻吸附到最近的目标(阈值内),否则原样返回。供片段条拖拽复用。 */
|
||||
function snapValueToTargets(candidate: number, targets: number[], thresholdSec: number): number {
|
||||
let best = candidate;
|
||||
let bestDist = thresholdSec;
|
||||
for (const t of targets) {
|
||||
const d = Math.abs(candidate - t);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = t;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function TimelineClipDragHandle(props: {
|
||||
duration: number;
|
||||
start: number;
|
||||
end: number;
|
||||
selected: boolean;
|
||||
className: string;
|
||||
style?: React.CSSProperties;
|
||||
minDuration: number;
|
||||
ariaLabel: string;
|
||||
title?: string;
|
||||
/** 占位(只读):不响应拖拽,点击交由 onClick 处理(如转为可编辑片段) */
|
||||
placeholder?: boolean;
|
||||
onClick?: () => void;
|
||||
/** 仅占位(只读)时无需提供:拖拽不会触发 */
|
||||
onChange?: (next: { start: number; end: number }) => void;
|
||||
/** 动态吸附目标:同轨其它片段边缘 / 关键帧 / 播放头 */
|
||||
snapTargets?: number[];
|
||||
/** 静态吸附目标:整帧 / 刻度网格 */
|
||||
gridSnapTargets?: number[];
|
||||
/** 吸附时显示参考线,拖拽结束传 null 隐藏 */
|
||||
onSnap?: (time: number | null) => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const dragRef = useRef<
|
||||
{ mode: "move" | "trimStart" | "trimEnd"; startX: number; origStart: number; origEnd: number; pointerId: number } | null
|
||||
>(null);
|
||||
|
||||
const beginDrag = (mode: "move" | "trimStart" | "trimEnd") => (event: React.PointerEvent) => {
|
||||
if (props.placeholder) return;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const lane = (event.currentTarget as HTMLElement).closest(".timeline-track-lane") as HTMLElement | null;
|
||||
if (!lane) return;
|
||||
dragRef.current = {
|
||||
mode,
|
||||
startX: event.clientX,
|
||||
origStart: props.start,
|
||||
origEnd: props.end,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
document.body.classList.add("is-timeline-dragging");
|
||||
try {
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// 部分环境(如 jsdom)未实现 setPointerCapture,忽略即可
|
||||
}
|
||||
// 抓取即选中(与轨迹条一致:pointerDown 即点亮片段),点击空白处再取消
|
||||
props.onClick?.();
|
||||
};
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
const lane = (event.currentTarget as HTMLElement).closest(".timeline-track-lane") as HTMLElement | null;
|
||||
if (!lane) return;
|
||||
const width = lane.getBoundingClientRect().width;
|
||||
const pxPerSecond = width > 0 ? width / props.duration : 0;
|
||||
if (pxPerSecond <= 0) return;
|
||||
const secondsPerPixel = 1 / pxPerSecond;
|
||||
const dt = (event.clientX - drag.startX) / pxPerSecond;
|
||||
const minDuration = props.minDuration;
|
||||
let nextStart = drag.origStart;
|
||||
let nextEnd = drag.origEnd;
|
||||
if (drag.mode === "move") {
|
||||
nextStart += dt;
|
||||
nextEnd += dt;
|
||||
} else if (drag.mode === "trimStart") {
|
||||
nextStart += dt;
|
||||
} else {
|
||||
nextEnd += dt;
|
||||
}
|
||||
|
||||
// 吸附:先吸动态目标(同轨其它片段边缘 / 关键帧 / 播放头),再吸整帧 / 刻度网格
|
||||
const thresholdSec = SNAP_THRESHOLD_PX * secondsPerPixel;
|
||||
const candidate = drag.mode === "trimEnd" ? nextEnd : nextStart;
|
||||
const dynamic = props.snapTargets ?? [];
|
||||
const grid = props.gridSnapTargets ?? [];
|
||||
let snapped = snapValueToTargets(candidate, dynamic, thresholdSec);
|
||||
let didSnap = Math.abs(snapped - candidate) > 1e-6;
|
||||
if (!didSnap) {
|
||||
const g = snapValueToTargets(candidate, grid, thresholdSec);
|
||||
if (Math.abs(g - candidate) > 1e-6) {
|
||||
snapped = g;
|
||||
didSnap = true;
|
||||
}
|
||||
}
|
||||
if (didSnap) {
|
||||
const delta = snapped - candidate;
|
||||
nextStart += delta;
|
||||
nextEnd += delta;
|
||||
}
|
||||
props.onSnap?.(didSnap ? snapped : null);
|
||||
|
||||
// 边界约束:修剪模式各自相对另一端夹紧;整体移动保持长度(与轨迹条一致)
|
||||
if (drag.mode === "trimStart") {
|
||||
nextStart = Math.max(0, Math.min(nextStart, nextEnd - minDuration));
|
||||
} else if (drag.mode === "trimEnd") {
|
||||
nextEnd = Math.min(props.duration, Math.max(nextEnd, nextStart + minDuration));
|
||||
} else {
|
||||
const len = drag.origEnd - drag.origStart;
|
||||
if (nextStart < 0) {
|
||||
nextStart = 0;
|
||||
nextEnd = len;
|
||||
} else if (nextEnd > props.duration) {
|
||||
nextEnd = props.duration;
|
||||
nextStart = props.duration - len;
|
||||
}
|
||||
}
|
||||
props.onChange?.({ start: nextStart, end: nextEnd });
|
||||
};
|
||||
|
||||
const endDrag = (event: React.PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
try {
|
||||
(event.currentTarget as HTMLElement).releasePointerCapture(dragRef.current.pointerId);
|
||||
} catch {
|
||||
// 忽略:指针已释放
|
||||
}
|
||||
dragRef.current = null;
|
||||
document.body.classList.remove("is-timeline-dragging");
|
||||
props.onSnap?.(null);
|
||||
};
|
||||
|
||||
// 方向键微调:左 / 右移起点或终点(Shift 加速),与轨道其它片段条一致的可访问性
|
||||
const nudge = (mode: "move" | "trimStart" | "trimEnd") => (event: React.KeyboardEvent) => {
|
||||
if (props.placeholder) return;
|
||||
const dir = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : 0;
|
||||
if (dir === 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const step = event.shiftKey ? 0.5 : 0.1;
|
||||
let nextStart = props.start;
|
||||
let nextEnd = props.end;
|
||||
if (mode === "move") {
|
||||
nextStart += dir * step;
|
||||
nextEnd += dir * step;
|
||||
} else if (mode === "trimStart") {
|
||||
nextStart += dir * step;
|
||||
} else {
|
||||
nextEnd += dir * step;
|
||||
}
|
||||
nextStart = Math.min(Math.max(nextStart, 0), props.duration);
|
||||
nextEnd = Math.min(Math.max(nextEnd, 0), props.duration);
|
||||
if (mode === "trimStart") {
|
||||
nextStart = Math.min(nextStart, nextEnd - props.minDuration);
|
||||
} else if (mode === "trimEnd") {
|
||||
nextEnd = Math.max(nextEnd, nextStart + props.minDuration);
|
||||
} else {
|
||||
const len = props.end - props.start;
|
||||
if (nextStart < 0) {
|
||||
nextStart = 0;
|
||||
nextEnd = len;
|
||||
}
|
||||
if (nextEnd > props.duration) {
|
||||
nextEnd = props.duration;
|
||||
nextStart = props.duration - len;
|
||||
}
|
||||
}
|
||||
props.onChange?.({ start: nextStart, end: nextEnd });
|
||||
};
|
||||
|
||||
const draggable = !props.placeholder;
|
||||
return (
|
||||
<div
|
||||
className={`${props.className}${props.selected ? " is-selected" : ""}${props.placeholder ? " is-placeholder" : ""}`}
|
||||
style={{
|
||||
left: `${props.duration > 0 ? (props.start / props.duration) * 100 : 0}%`,
|
||||
width: `${props.duration > 0 ? ((props.end - props.start) / props.duration) * 100 : 0}%`,
|
||||
...props.style,
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={props.ariaLabel}
|
||||
title={props.title}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
props.onClick?.();
|
||||
}}
|
||||
onPointerDown={draggable ? beginDrag("move") : undefined}
|
||||
onPointerMove={draggable ? onPointerMove : undefined}
|
||||
onPointerUp={draggable ? endDrag : undefined}
|
||||
onPointerCancel={draggable ? endDrag : undefined}
|
||||
onKeyDown={draggable ? nudge("move") : undefined}
|
||||
>
|
||||
{!props.placeholder && (
|
||||
<span
|
||||
className="timeline-trajectory-trim is-pre"
|
||||
data-tl="trim"
|
||||
data-side="pre"
|
||||
title="拖拽修剪起点"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={`${props.ariaLabel} 起点`}
|
||||
onPointerDown={beginDrag("trimStart")}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onKeyDown={nudge("trimStart")}
|
||||
/>
|
||||
)}
|
||||
{props.children}
|
||||
{!props.placeholder && (
|
||||
<span
|
||||
className="timeline-trajectory-trim is-post"
|
||||
data-tl="trim"
|
||||
data-side="post"
|
||||
title="拖拽修剪终点"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label={`${props.ariaLabel} 终点`}
|
||||
onPointerDown={beginDrag("trimEnd")}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onKeyDown={nudge("trimEnd")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 运镜(过渡)片段编辑器:改前后机位、过渡类型、删除 */
|
||||
function TransitionClipEditor(props: {
|
||||
clip: TransitionClip;
|
||||
cameras: DirectorCameraShot[];
|
||||
duration: number;
|
||||
onUpdate: (patch: Partial<TransitionClip>) => void;
|
||||
onRemove: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const cameraOptions = props.cameras.map((camera) => ({ value: camera.id, label: camera.name }));
|
||||
const smooth = props.clip.type === "smooth";
|
||||
return (
|
||||
<div className="timeline-transition-editor" role="dialog" aria-label="编辑运镜(过渡)片段">
|
||||
<div className="timeline-transition-editor-section">
|
||||
<div className="timeline-transition-editor-section-title">连接前后机位</div>
|
||||
<div className="timeline-transition-editor-row">
|
||||
<span className="timeline-transition-editor-label">来源</span>
|
||||
<InspectorDropdown
|
||||
className="timeline-transition-dropdown"
|
||||
ariaLabel="过渡来源机位"
|
||||
value={props.clip.fromCameraId ?? "__auto__"}
|
||||
options={[{ value: "__auto__", label: "自动(上一激活机位)" }, ...cameraOptions]}
|
||||
onChange={(value) => props.onUpdate({ fromCameraId: value === "__auto__" ? null : value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="timeline-transition-editor-row">
|
||||
<span className="timeline-transition-editor-label">切到</span>
|
||||
<InspectorDropdown
|
||||
className="timeline-transition-dropdown"
|
||||
ariaLabel="过渡目标机位"
|
||||
value={props.clip.toCameraId}
|
||||
options={cameraOptions}
|
||||
onChange={(value) => props.onUpdate({ toCameraId: value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="timeline-transition-editor-row">
|
||||
<span className="timeline-transition-editor-label">类型</span>
|
||||
<InspectorDropdown
|
||||
className="timeline-transition-dropdown"
|
||||
ariaLabel="过渡类型"
|
||||
value={props.clip.type}
|
||||
options={[
|
||||
{ value: "cut", label: "硬切" },
|
||||
{ value: "smooth", label: "平滑运镜" },
|
||||
]}
|
||||
onChange={(value) => props.onUpdate({ type: value as CameraTransitionType })}
|
||||
/>
|
||||
</div>
|
||||
<div className="timeline-transition-editor-row">
|
||||
<span className="timeline-transition-editor-label">时长</span>
|
||||
<span className="timeline-transition-editor-duration-field">
|
||||
<input
|
||||
className="timeline-settings-input timeline-transition-duration-input"
|
||||
aria-label="过渡时长(秒)"
|
||||
type="number"
|
||||
min={TRANSITION_CLIP_MIN_DURATION}
|
||||
max={props.duration}
|
||||
step={0.1}
|
||||
value={Number((props.clip.end - props.clip.start).toFixed(2))}
|
||||
disabled={!smooth}
|
||||
onChange={(event) => {
|
||||
const dur = Math.max(
|
||||
TRANSITION_CLIP_MIN_DURATION,
|
||||
Math.min(Number(event.target.value) || TRANSITION_CLIP_MIN_DURATION, props.duration)
|
||||
);
|
||||
props.onUpdate({ end: props.clip.start + dur });
|
||||
}}
|
||||
/>
|
||||
<span className="timeline-settings-unit">秒</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="timeline-transition-editor-actions">
|
||||
<button type="button" className="timeline-transition-editor-delete" onClick={props.onRemove}>
|
||||
删除此过渡
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-transition-editor-close"
|
||||
aria-label="关闭编辑器"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X aria-hidden="true" size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 机位活跃片段编辑器:改起止、删除 */
|
||||
function CameraClipEditor(props: {
|
||||
clip: CameraActiveClip;
|
||||
duration: number;
|
||||
onUpdate: (patch: Partial<CameraActiveClip>) => void;
|
||||
onRemove: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const timeField = (label: string, value: number, onValue: (next: number) => void) => (
|
||||
<div className="timeline-transition-editor-row">
|
||||
<span className="timeline-transition-editor-label">{label}</span>
|
||||
<span className="timeline-transition-editor-duration-field">
|
||||
<input
|
||||
className="timeline-settings-input timeline-transition-duration-input"
|
||||
aria-label={`${label}(秒)`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={props.duration}
|
||||
step={0.1}
|
||||
value={Number(value.toFixed(2))}
|
||||
onChange={(event) => {
|
||||
const next = Math.min(
|
||||
Math.max(Number(event.target.value) || 0, 0),
|
||||
props.duration
|
||||
);
|
||||
onValue(next);
|
||||
}}
|
||||
/>
|
||||
<span className="timeline-settings-unit">秒</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="timeline-transition-editor" role="dialog" aria-label="编辑机位片段">
|
||||
<div className="timeline-transition-editor-section">
|
||||
<div className="timeline-transition-editor-section-title">机位活跃片段</div>
|
||||
{timeField("起点", props.clip.start, (next) =>
|
||||
props.onUpdate({ start: Math.min(next, props.clip.end - CAMERA_CLIP_MIN_DURATION) })
|
||||
)}
|
||||
{timeField("终点", props.clip.end, (next) =>
|
||||
props.onUpdate({ end: Math.max(next, props.clip.start + CAMERA_CLIP_MIN_DURATION) })
|
||||
)}
|
||||
</div>
|
||||
<div className="timeline-transition-editor-actions">
|
||||
<button type="button" className="timeline-transition-editor-delete" onClick={props.onRemove}>
|
||||
删除此片段
|
||||
</button>
|
||||
<button type="button" className="timeline-transition-editor-close" aria-label="关闭编辑器" onClick={props.onClose}>
|
||||
<X aria-hidden="true" size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelinePanel() {
|
||||
const project = useDirectorStore((state) => state.project);
|
||||
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
|
||||
@@ -334,7 +725,12 @@ export function TimelinePanel() {
|
||||
const updateKeyframeEasing = useDirectorStore((state) => state.updateKeyframeEasing);
|
||||
const updateExportSettings = useDirectorStore((state) => state.updateExportSettings);
|
||||
const updateCameraKeyframeEasing = useDirectorStore((state) => state.updateCameraKeyframeEasing);
|
||||
const setCameraKeyframeTransition = useDirectorStore((state) => state.setCameraKeyframeTransition);
|
||||
const addTransitionClip = useDirectorStore((state) => state.addTransitionClip);
|
||||
const updateTransitionClip = useDirectorStore((state) => state.updateTransitionClip);
|
||||
const removeTransitionClip = useDirectorStore((state) => state.removeTransitionClip);
|
||||
const addCameraClip = useDirectorStore((state) => state.addCameraClip);
|
||||
const updateCameraClip = useDirectorStore((state) => state.updateCameraClip);
|
||||
const removeCameraClip = useDirectorStore((state) => state.removeCameraClip);
|
||||
const setCameraKeyframeTimes = useDirectorStore((state) => state.setCameraKeyframeTimes);
|
||||
const applyCameraKeyframeToShot = useDirectorStore((state) => state.applyCameraKeyframeToShot);
|
||||
const setActiveCamera = useDirectorStore((state) => state.setActiveCamera);
|
||||
@@ -404,19 +800,21 @@ export function TimelinePanel() {
|
||||
// 轨道拖拽:生命周期(谁在拖 → 高亮 / 毛玻璃降级)走 React state,一次拖拽只重渲染两次;
|
||||
// 高频的落点线则完全不经过 React(ref + transform),详见 getTrackDragProps。
|
||||
const [draggingTrackKey, setDraggingTrackKey] = useState<string | null>(null);
|
||||
/** 当前在「机位切换」概览轨中选中的过渡(按切点关键帧 id 定位),用于弹出编辑器 */
|
||||
const [selectedCutKeyframeId, setSelectedCutKeyframeId] = useState<string | null>(null);
|
||||
/** 当前在「运镜(过渡)轨」中选中的过渡片段,用于弹出编辑器 */
|
||||
const [selectedTransitionClipId, setSelectedTransitionClipId] = useState<string | null>(null);
|
||||
/** 当前在机位轨道中选中的活跃片段(格式 cameraId:clipId),用于弹出编辑器 */
|
||||
const [selectedCameraClipKey, setSelectedCameraClipKey] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!selectedCutKeyframeId) return;
|
||||
if (!selectedTransitionClipId) return;
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest(".timeline-transition-editor")) return;
|
||||
if (target.closest(".timeline-transition-marker")) return;
|
||||
setSelectedCutKeyframeId(null);
|
||||
if (target.closest(".timeline-transition-clip")) return;
|
||||
setSelectedTransitionClipId(null);
|
||||
}
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||
}, [selectedCutKeyframeId]);
|
||||
}, [selectedTransitionClipId]);
|
||||
const dropTargetRef = useRef<{ key: string; position: TimelineTrackDropPosition } | null>(null);
|
||||
const dropIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
/** 吸附参考线:拖动片段 / 关键帧时,边缘吸到其它片段边缘或播放头处显示一条高亮竖线 */
|
||||
@@ -451,6 +849,8 @@ export function TimelinePanel() {
|
||||
/** 拖拽开始时该角色的全部动作片段(用于「拖动换位」:依据原始布局做确定性重排) */
|
||||
startClips?: MotionClip[];
|
||||
} | null>(null);
|
||||
/** 拖关键帧 / 轨迹点时注册的 Esc 监听,结束后移除 */
|
||||
const cancelTimeDragOnEscapeRef = useRef<((event: KeyboardEvent) => void) | null>(null);
|
||||
/** 拖拽开始时一次性量好的行几何:移动中只查表,绝不在事件里读布局(强制同步布局 = layout thrashing) */
|
||||
const trackRectsRef = useRef(new Map<string, TrackRowRect>());
|
||||
const trackLayoutOriginRef = useRef(0);
|
||||
@@ -942,7 +1342,7 @@ export function TimelinePanel() {
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
event.target.closest(
|
||||
".timeline-trajectory-bar, .timeline-keyframe, .timeline-trajectory-trim, .timeline-clip-delete, .timeline-add-clip-button, .timeline-track-icon-button"
|
||||
".timeline-trajectory-bar, .timeline-keyframe, .timeline-trajectory-trim, .timeline-clip-delete, .timeline-add-clip-button, .timeline-track-icon-button, .timeline-clip-bar-camera, .timeline-clip-bar-transition"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -953,6 +1353,8 @@ export function TimelinePanel() {
|
||||
setSelectedKeyframe(null);
|
||||
selectTrajectoryPoint(null, null);
|
||||
selectMotionClip(null);
|
||||
setSelectedCameraClipKey(null);
|
||||
setSelectedTransitionClipId(null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1154,6 +1556,17 @@ export function TimelinePanel() {
|
||||
} catch {
|
||||
// jsdom 等环境不支持 pointer capture,忽略后仍能继续拖动
|
||||
}
|
||||
|
||||
// 关键帧 / 轨迹点拖动时,按 Esc 取消并还原到拖拽前的位置
|
||||
if (kind === "point") {
|
||||
const onEscape = (keyEvent: KeyboardEvent) => {
|
||||
if (keyEvent.key !== "Escape") return;
|
||||
keyEvent.preventDefault();
|
||||
cancelTimeDrag();
|
||||
};
|
||||
document.addEventListener("keydown", onEscape);
|
||||
cancelTimeDragOnEscapeRef.current = onEscape;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTimeDragMove(event: ReactPointerEvent<HTMLElement>) {
|
||||
@@ -1186,6 +1599,20 @@ export function TimelinePanel() {
|
||||
moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, delta, drag.endEdge);
|
||||
}
|
||||
|
||||
function cancelTimeDrag() {
|
||||
const drag = timeDragRef.current;
|
||||
if (!drag) return;
|
||||
// 用 delta=0 重新套用按下时记录的原始时刻,精确还原到拖拽前的位置
|
||||
moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, 0, drag.endEdge);
|
||||
hideSnapIndicator();
|
||||
document.body.classList.remove("is-timeline-dragging");
|
||||
timeDragRef.current = null;
|
||||
if (cancelTimeDragOnEscapeRef.current) {
|
||||
document.removeEventListener("keydown", cancelTimeDragOnEscapeRef.current);
|
||||
cancelTimeDragOnEscapeRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTimeDragEnd(event: ReactPointerEvent<HTMLElement>) {
|
||||
const drag = timeDragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
@@ -1193,6 +1620,10 @@ export function TimelinePanel() {
|
||||
timeDragRef.current = null;
|
||||
hideSnapIndicator();
|
||||
document.body.classList.remove("is-timeline-dragging");
|
||||
if (cancelTimeDragOnEscapeRef.current) {
|
||||
document.removeEventListener("keydown", cancelTimeDragOnEscapeRef.current);
|
||||
cancelTimeDragOnEscapeRef.current = null;
|
||||
}
|
||||
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
@@ -1899,157 +2330,102 @@ export function TimelinePanel() {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 机位切换概览轨:把每个切换切点画成过渡标记(硬切=竖线、平滑=渐变区),点击可编辑类型/时长(逐切点覆盖全局默认) */}
|
||||
{(() => {
|
||||
const cuts = getCameraCutSegments(
|
||||
timeline.cameraTracks,
|
||||
timeline.cameraTransition,
|
||||
project.activeCameraId
|
||||
);
|
||||
if (cuts.length === 0) return null;
|
||||
const selectedCut = cuts.find((item) => item.keyframe.id === selectedCutKeyframeId) ?? null;
|
||||
const selLeft = selectedCut && duration > 0 ? (selectedCut.time / duration) * 100 : 0;
|
||||
return (
|
||||
<div className="timeline-track timeline-track-transition">
|
||||
<div className="timeline-track-label" title="机位切换:每个切换点的过渡类型与时长,点击可编辑">
|
||||
<Repeat aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
|
||||
<span className="timeline-track-name">机位切换</span>
|
||||
<span className="timeline-track-count">{cuts.length}</span>
|
||||
</div>
|
||||
<div className="timeline-track-lane timeline-transition-lane">
|
||||
{cuts.map((cut, index) => {
|
||||
const leftPercent = duration > 0 ? (cut.time / duration) * 100 : 0;
|
||||
const nextTime = cuts[index + 1]?.time ?? duration;
|
||||
const maxDur = Math.max(0, nextTime - cut.time);
|
||||
const shownDuration =
|
||||
cut.transition.type === "smooth"
|
||||
? Math.min(cut.transition.duration, maxDur > 0 ? maxDur : cut.transition.duration)
|
||||
: 0;
|
||||
const widthPercent = duration > 0 ? (shownDuration / duration) * 100 : 0;
|
||||
const isSelected = cut.keyframe.id === selectedCutKeyframeId;
|
||||
const fromName =
|
||||
project.cameras.find((c) => c.id === cut.fromCameraId)?.name ?? (cut.fromCameraId ?? "起始");
|
||||
const toName = project.cameras.find((c) => c.id === cut.toCameraId)?.name ?? cut.toCameraId;
|
||||
const transitionLabel =
|
||||
cut.transition.type === "smooth" ? `平滑运镜 ${formatTime(cut.transition.duration)}` : "硬切";
|
||||
return (
|
||||
<div
|
||||
key={cut.keyframe.id}
|
||||
className={`timeline-transition-marker timeline-transition-${cut.transition.type}${
|
||||
isSelected ? " is-selected" : ""
|
||||
}`}
|
||||
style={
|
||||
cut.transition.type === "smooth"
|
||||
? { left: `${leftPercent}%`, width: `${widthPercent}%` }
|
||||
: { left: `${leftPercent}%` }
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`机位切换 ${formatTime(cut.time)} · ${fromName} → ${toName} · ${transitionLabel}`}
|
||||
title={`${fromName} → ${toName} · ${transitionLabel}(点击编辑)`}
|
||||
onClick={() => setSelectedCutKeyframeId(isSelected ? null : cut.keyframe.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setSelectedCutKeyframeId(isSelected ? null : cut.keyframe.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{cut.transition.type === "smooth" ? (
|
||||
<span className="timeline-transition-duration">{formatTime(cut.transition.duration)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{selectedCut ? (
|
||||
<div
|
||||
className="timeline-transition-editor"
|
||||
style={{ left: `${selLeft}%` }}
|
||||
role="dialog"
|
||||
aria-label="编辑机位切换过渡"
|
||||
>
|
||||
<div className="timeline-transition-editor-row">
|
||||
<span className="timeline-transition-editor-label">类型</span>
|
||||
<InspectorDropdown
|
||||
className="timeline-transition-dropdown"
|
||||
ariaLabel="切换类型"
|
||||
value={selectedCut.transition.type}
|
||||
options={[
|
||||
{ value: "cut", label: "硬切" },
|
||||
{ value: "smooth", label: "平滑运镜" },
|
||||
]}
|
||||
onChange={(value) =>
|
||||
setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, {
|
||||
type: value as CameraTransitionType,
|
||||
duration: selectedCut.transition.duration,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="timeline-transition-editor-row">
|
||||
<span className="timeline-transition-editor-label">时长</span>
|
||||
<div className="timeline-transition-editor-duration-field">
|
||||
<input
|
||||
className="timeline-settings-input timeline-transition-duration-input"
|
||||
aria-label="过渡时长(秒)"
|
||||
type="number"
|
||||
min={MIN_CAMERA_TRANSITION_DURATION}
|
||||
max={MAX_CAMERA_TRANSITION_DURATION}
|
||||
step={0.1}
|
||||
value={selectedCut.transition.duration}
|
||||
disabled={selectedCut.transition.type !== "smooth"}
|
||||
onChange={(event) =>
|
||||
setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, {
|
||||
type: selectedCut.transition.type,
|
||||
duration: Math.min(
|
||||
MAX_CAMERA_TRANSITION_DURATION,
|
||||
Math.max(MIN_CAMERA_TRANSITION_DURATION, Number(event.target.value))
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="timeline-settings-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="timeline-transition-editor-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-transition-editor-reset"
|
||||
disabled={!selectedCut.keyframe.transition}
|
||||
onClick={() => setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, null)}
|
||||
>
|
||||
重置为全局默认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-transition-editor-close"
|
||||
aria-label="关闭编辑器"
|
||||
onClick={() => setSelectedCutKeyframeId(null)}
|
||||
>
|
||||
<X aria-hidden="true" size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* 运镜(过渡)轨:独立的「运镜轨道」,可单独添加过渡片段连接两个机位;
|
||||
片段可拖拽移动、可修剪两端、可改前后机位、可删除。连接由时间序唯一决定。 */}
|
||||
<div className="timeline-track timeline-track-transition">
|
||||
<div
|
||||
className="timeline-track-label"
|
||||
title="运镜(过渡)轨:拖拽片段在时间轴上移动即可改变切换时机,点击片段编辑前后机位与过渡类型"
|
||||
>
|
||||
<Repeat aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
|
||||
<span className="timeline-track-name">运镜(过渡)</span>
|
||||
<span className="timeline-track-count">{timeline.transitionClips.length}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-track-add-button"
|
||||
aria-label="添加过渡片段"
|
||||
onClick={() => {
|
||||
const fromId = project.activeCameraId;
|
||||
const toId =
|
||||
project.cameras.find((c) => c.id !== fromId)?.id ?? project.cameras[0]?.id ?? "";
|
||||
if (!toId) return;
|
||||
const id = addTransitionClip(fromId, toId);
|
||||
setSelectedTransitionClipId(id);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="timeline-track-lane timeline-transition-lane">
|
||||
{timeline.transitionClips.map((clip) => {
|
||||
const toColor = project.cameras.find((c) => c.id === clip.toCameraId)?.color ?? "#0CA678";
|
||||
const fromColor =
|
||||
(clip.fromCameraId && project.cameras.find((c) => c.id === clip.fromCameraId)?.color) || toColor;
|
||||
const smooth = clip.type === "smooth";
|
||||
const isSelected = clip.id === selectedTransitionClipId;
|
||||
const fromName =
|
||||
(clip.fromCameraId && project.cameras.find((c) => c.id === clip.fromCameraId)?.name) || "自动";
|
||||
const toName = project.cameras.find((c) => c.id === clip.toCameraId)?.name ?? clip.toCameraId;
|
||||
const background = smooth
|
||||
? `linear-gradient(90deg, rgb(${hexToRgbTriple(fromColor)}), rgb(${hexToRgbTriple(toColor)}))`
|
||||
: `rgb(${hexToRgbTriple(toColor)})`;
|
||||
return (
|
||||
<TimelineClipDragHandle
|
||||
key={clip.id}
|
||||
duration={duration}
|
||||
start={clip.start}
|
||||
end={clip.end}
|
||||
minDuration={TRANSITION_CLIP_MIN_DURATION}
|
||||
selected={isSelected}
|
||||
className="timeline-trajectory-bar timeline-clip-bar-transition"
|
||||
style={{ background }}
|
||||
ariaLabel={`运镜过渡 ${fromName} → ${toName}`}
|
||||
title={`${fromName} → ${toName}(${smooth ? "平滑运镜" : "硬切"})· 点击编辑前后机位`}
|
||||
onClick={() => setSelectedTransitionClipId(isSelected ? null : clip.id)}
|
||||
onChange={(next) => updateTransitionClip(clip.id, next)}
|
||||
snapTargets={[
|
||||
playhead,
|
||||
...timeline.transitionClips
|
||||
.filter((c) => c.id !== clip.id)
|
||||
.flatMap((c) => [c.start, c.end]),
|
||||
...timeline.cameraTracks.flatMap((t) => t.keyframes.map((k) => k.time)),
|
||||
]}
|
||||
gridSnapTargets={buildGridSnapTargets()}
|
||||
onSnap={showSnapIndicator}
|
||||
>
|
||||
<span className="timeline-trajectory-bar-label">{toName}</span>
|
||||
</TimelineClipDragHandle>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
const selected = timeline.transitionClips.find((item) => item.id === selectedTransitionClipId) ?? null;
|
||||
if (!selected) return null;
|
||||
return (
|
||||
<TransitionClipEditor
|
||||
clip={selected}
|
||||
cameras={project.cameras}
|
||||
duration={duration}
|
||||
onUpdate={(patch) => updateTransitionClip(selected.id, patch)}
|
||||
onRemove={() => {
|
||||
removeTransitionClip(selected.id);
|
||||
setSelectedTransitionClipId(null);
|
||||
}}
|
||||
onClose={() => setSelectedTransitionClipId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timeline.cameraTracks.map((track) => {
|
||||
const shot = project.cameras.find((c) => c.id === track.cameraId);
|
||||
const trackName = shot?.name ?? track.cameraId;
|
||||
const cameraColor = shot?.color ?? "#0CA678";
|
||||
const trackKey = getTimelineTrackKey("camera", track.cameraId);
|
||||
const collapsed = timelineCollapsedTracks.includes(trackKey);
|
||||
const dragProps = getTrackDragProps(trackKey);
|
||||
// 运镜片段:第一个关键帧 → 最后一个关键帧,这段区间就是「这一段运镜」的时长
|
||||
// 机位内运镜:关键帧时间点(用于子轨道渲染 + 连线)
|
||||
const cameraTimes = track.keyframes.map((keyframe) => keyframe.time);
|
||||
const clipSpan = getCameraClipSpan(track.keyframes);
|
||||
const clipStart = clipSpan?.start ?? 0;
|
||||
const clipEnd = clipSpan?.end ?? 0;
|
||||
const clipDuration = clipEnd - clipStart;
|
||||
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 = 轨道垂直中点),直观呈现运镜过程
|
||||
@@ -2064,7 +2440,7 @@ export function TimelinePanel() {
|
||||
}`}
|
||||
key={track.cameraId}
|
||||
data-track-key={trackKey}
|
||||
style={{ order: getTrackOrder(trackKey) }}
|
||||
style={{ order: getTrackOrder(trackKey), "--camera-color": hexToRgbTriple(cameraColor) } as CSSProperties}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="timeline-track timeline-track-camera">
|
||||
@@ -2074,67 +2450,115 @@ export function TimelinePanel() {
|
||||
<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}
|
||||
<span className="timeline-track-summary">
|
||||
{track.clips.length} 段活跃 · {track.keyframes.length} 个运镜关键帧
|
||||
</span>
|
||||
) : track.clips.length === 0 ? (
|
||||
track.keyframes.length >= 2 ? (
|
||||
// 仅打了运镜关键帧、尚未显式添加片段:按「首帧→末帧」自动推导一个只读占位活跃窗口,
|
||||
// 点击即转为可编辑的机位活跃片段;求值层 getClipCameraAtTime 也会同等兜底,保证「打关键帧即生效」
|
||||
(() => {
|
||||
const pStart = Math.min(...cameraTimes);
|
||||
const pEnd = Math.max(...cameraTimes);
|
||||
return (
|
||||
<TimelineClipDragHandle
|
||||
duration={duration}
|
||||
start={pStart}
|
||||
end={pEnd}
|
||||
minDuration={CAMERA_CLIP_MIN_DURATION}
|
||||
selected={false}
|
||||
placeholder
|
||||
className="timeline-trajectory-bar timeline-clip-bar-camera"
|
||||
style={{ "--camera-color": hexToRgbTriple(cameraColor) } as CSSProperties}
|
||||
ariaLabel={`${trackName} 运镜区间(默认活跃窗口)`}
|
||||
title={`${trackName} 运镜区间 ${formatTime(pStart)} → ${formatTime(
|
||||
pEnd
|
||||
)}(点击转为可编辑片段)`}
|
||||
onClick={() => {
|
||||
const id = addCameraClip(track.cameraId, pStart, pEnd);
|
||||
setSelectedCameraClipKey(`${track.cameraId}:${id}`);
|
||||
}}
|
||||
>
|
||||
<span className="timeline-trajectory-bar-label">{formatTime(pEnd - pStart)}</span>
|
||||
</TimelineClipDragHandle>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-add-clip-button"
|
||||
onClick={() => {
|
||||
const end = Math.min(DEFAULT_CAMERA_TRANSITION_DURATION, duration);
|
||||
const id = addCameraClip(track.cameraId, 0, end);
|
||||
setSelectedCameraClipKey(`${track.cameraId}:${id}`);
|
||||
}}
|
||||
>
|
||||
+ 添加片段
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{track.clips.map((clip) => {
|
||||
const isSelected = selectedCameraClipKey === `${track.cameraId}:${clip.id}`;
|
||||
return (
|
||||
<TimelineClipDragHandle
|
||||
key={clip.id}
|
||||
duration={duration}
|
||||
start={clip.start}
|
||||
end={clip.end}
|
||||
minDuration={CAMERA_CLIP_MIN_DURATION}
|
||||
selected={isSelected}
|
||||
className="timeline-trajectory-bar timeline-clip-bar-camera"
|
||||
style={{ "--camera-color": hexToRgbTriple(cameraColor) } as CSSProperties}
|
||||
ariaLabel={`${trackName} 片段`}
|
||||
title={`${trackName} 片段 ${formatTime(clip.start)} → ${formatTime(
|
||||
clip.end
|
||||
)}(点击编辑起止)`}
|
||||
onClick={() =>
|
||||
setSelectedCameraClipKey(isSelected ? null : `${track.cameraId}:${clip.id}`)
|
||||
}
|
||||
onChange={(next) => updateCameraClip(track.cameraId, clip.id, next)}
|
||||
snapTargets={[
|
||||
playhead,
|
||||
...timeline.cameraTracks.flatMap((t) =>
|
||||
t.clips
|
||||
.filter((c) => !(t.cameraId === track.cameraId && c.id === clip.id))
|
||||
.flatMap((c) => [c.start, c.end])
|
||||
),
|
||||
...track.keyframes.map((k) => k.time),
|
||||
...timeline.transitionClips.flatMap((c) => [c.start, c.end]),
|
||||
]}
|
||||
gridSnapTargets={buildGridSnapTargets()}
|
||||
onSnap={showSnapIndicator}
|
||||
>
|
||||
<span className="timeline-trajectory-bar-label">{formatTime(clip.end - clip.start)}</span>
|
||||
</TimelineClipDragHandle>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
const selId = selectedCameraClipKey?.startsWith(`${track.cameraId}:`)
|
||||
? selectedCameraClipKey.slice(track.cameraId.length + 1)
|
||||
: null;
|
||||
const sel = selId ? track.clips.find((c) => c.id === selId) ?? null : null;
|
||||
if (!sel) return null;
|
||||
return (
|
||||
<CameraClipEditor
|
||||
clip={sel}
|
||||
duration={duration}
|
||||
onUpdate={(patch) => updateCameraClip(track.cameraId, sel.id, patch)}
|
||||
onRemove={() => {
|
||||
removeCameraClip(track.cameraId, sel.id);
|
||||
setSelectedCameraClipKey(null);
|
||||
}}
|
||||
onClose={() => setSelectedCameraClipKey(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: string | null }) {
|
||||
const camera = useThree((state) => state.camera) as ThreePerspectiveCamera;
|
||||
const cameraTracks = useDirectorStore((state) => state.project.timeline.cameraTracks);
|
||||
const transitionClips = useDirectorStore((state) => state.project.timeline.transitionClips);
|
||||
const cameraTransition = useDirectorStore((state) => state.project.timeline.cameraTransition);
|
||||
const cameras = useDirectorStore((state) => state.project.cameras);
|
||||
const projectScene = useDirectorStore((state) => state.project.scene);
|
||||
@@ -40,7 +41,7 @@ export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: s
|
||||
// 播放 / 导出 / 拖动标尺 scrub 时按关键帧自动切机位;其它时候沿用手动选中的机位
|
||||
const autoCut = isPlaying || isExporting || isScrubbing;
|
||||
const displayCameraId = autoCut
|
||||
? getActiveCameraIdAtTime(cameraTracks, activeCameraId, playhead)
|
||||
? getActiveCameraIdAtTime(transitionClips, cameraTracks, activeCameraId, playhead)
|
||||
: activeCameraId;
|
||||
|
||||
// 播放或 scrub 且切点变化时把切换写回工程,让工具条 / 相机面板跟随当前机位;
|
||||
@@ -52,7 +53,10 @@ export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: s
|
||||
// 机位视角下覆盖渲染相机(播放 + scrub + 导出预览均生效),导演视角 / 编辑中不覆盖
|
||||
const appliedCameraTrack = applyActiveCameraViewAtTime({
|
||||
camera,
|
||||
transitionClips,
|
||||
cameraTracks,
|
||||
cameras,
|
||||
projectScene,
|
||||
activeCameraId: displayCameraId,
|
||||
playhead,
|
||||
viewMode,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Object3D, PerspectiveCamera, Vector3 } from "three";
|
||||
import type { DirectorObject } from "../schema/directorProject";
|
||||
import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline";
|
||||
import type { CameraTimelineTrack, TimelineTrack, TransitionClip } from "../schema/timeline";
|
||||
import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "./playbackTransforms";
|
||||
import { computeExportSize } from "../io/videoExport";
|
||||
import { getCameraLookQuaternion } from "../schema/cameraGeometry";
|
||||
@@ -10,6 +10,7 @@ import { getActiveCameraIdAtTime } from "../schema/timeline";
|
||||
function makeCameraTrackAt(cameraId: string, times: number[]): CameraTimelineTrack {
|
||||
return {
|
||||
cameraId,
|
||||
clips: [],
|
||||
keyframes: times.map((time, index) => ({
|
||||
id: `${cameraId}-${index}`,
|
||||
time,
|
||||
@@ -54,6 +55,7 @@ function makeObjectTrack(): TimelineTrack {
|
||||
function makeCameraTrack(): CameraTimelineTrack {
|
||||
return {
|
||||
cameraId: "cam-1",
|
||||
clips: [],
|
||||
keyframes: [
|
||||
{ id: "ckf-1", time: 0, value: { position: [0, 1, 0], lookRotation: [0, 0, 0], fov: 50 } },
|
||||
{ id: "ckf-2", time: 2, value: { position: [2, 1, 0], lookRotation: [0, 90, 0], fov: 60 } },
|
||||
@@ -165,6 +167,7 @@ it("overrides the render camera in camera view mode", () => {
|
||||
|
||||
const applied = applyActiveCameraViewAtTime({
|
||||
camera,
|
||||
transitionClips: [],
|
||||
cameraTracks: [makeCameraTrack()],
|
||||
activeCameraId: "cam-1",
|
||||
playhead: 2,
|
||||
@@ -185,6 +188,7 @@ it("does not override the render camera in director view mode", () => {
|
||||
|
||||
const applied = applyActiveCameraViewAtTime({
|
||||
camera,
|
||||
transitionClips: [],
|
||||
cameraTracks: [makeCameraTrack()],
|
||||
activeCameraId: "cam-1",
|
||||
playhead: 2,
|
||||
@@ -199,41 +203,45 @@ it("does not override the render camera in director view mode", () => {
|
||||
});
|
||||
|
||||
describe("getActiveCameraIdAtTime", () => {
|
||||
// camA 在 1s / 5s 打点,camB 在 3s / 7s 打点:播放时应按「最近一帧 ≤ 播放头」切机位
|
||||
const tracks = [makeCameraTrackAt("camA", [1, 5]), makeCameraTrackAt("camB", [3, 7])];
|
||||
// 新模型:机位切换由「机位活跃片段(clips) + 运镜(过渡)片段」按时间唯一决定
|
||||
const tracks = [
|
||||
{ cameraId: "camA", clips: [{ id: "a1", start: 0, end: 3 }], keyframes: [] },
|
||||
{ cameraId: "camB", clips: [{ id: "b1", start: 3, end: 7 }], keyframes: [] },
|
||||
];
|
||||
const noTransition: TransitionClip[] = [];
|
||||
|
||||
it("falls back before any camera has a keyframe", () => {
|
||||
expect(getActiveCameraIdAtTime(tracks, "camA", 0)).toBe("camA");
|
||||
it("uses the clip containing the time", () => {
|
||||
expect(getActiveCameraIdAtTime(noTransition, tracks, null, 1)).toBe("camA");
|
||||
expect(getActiveCameraIdAtTime(noTransition, tracks, null, 5)).toBe("camB");
|
||||
});
|
||||
|
||||
it("uses the only camera that has a keyframe", () => {
|
||||
// t=2:camA 最近帧 1s,camB 还没有 → camA
|
||||
expect(getActiveCameraIdAtTime(tracks, "camB", 2)).toBe("camA");
|
||||
it("falls back to the latest-ended clip camera after all clips end", () => {
|
||||
expect(getActiveCameraIdAtTime(noTransition, tracks, null, 9)).toBe("camB");
|
||||
});
|
||||
|
||||
it("cuts to the camera with the most recent keyframe", () => {
|
||||
// t=4:camA 1s、camB 3s → camB(3 > 1)
|
||||
expect(getActiveCameraIdAtTime(tracks, "camB", 4)).toBe("camB");
|
||||
// t=6:camA 5s、camB 3s → camA(5 > 3)
|
||||
expect(getActiveCameraIdAtTime(tracks, "camA", 6)).toBe("camA");
|
||||
// t=8:camB 7s 领先 → camB
|
||||
expect(getActiveCameraIdAtTime(tracks, "camB", 8)).toBe("camB");
|
||||
it("falls back when no clip covers the time and none ended before", () => {
|
||||
expect(getActiveCameraIdAtTime(noTransition, tracks, "camB", 0)).toBe("camA");
|
||||
});
|
||||
|
||||
it("keeps a single camera on screen the whole time", () => {
|
||||
const single = [makeCameraTrackAt("camA", [0, 2, 4])];
|
||||
expect(getActiveCameraIdAtTime(single, "camA", 0)).toBe("camA");
|
||||
expect(getActiveCameraIdAtTime(single, "camA", 3)).toBe("camA");
|
||||
expect(getActiveCameraIdAtTime(single, "camA", 9)).toBe("camA");
|
||||
it("falls back to fallback when no clips at all", () => {
|
||||
expect(getActiveCameraIdAtTime(noTransition, [{ cameraId: "camA", clips: [], keyframes: [] }], "camB", 5)).toBe(
|
||||
"camB"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back when no track has keyframes", () => {
|
||||
expect(getActiveCameraIdAtTime([makeCameraTrackAt("camA", [])], "camB", 5)).toBe("camB");
|
||||
it("prefers the transition's to-camera while a transition is active", () => {
|
||||
const transitions: TransitionClip[] = [
|
||||
{ id: "t1", start: 3, end: 4, fromCameraId: "camA", toCameraId: "camB", type: "smooth" },
|
||||
];
|
||||
expect(getActiveCameraIdAtTime(transitions, tracks, null, 3.5)).toBe("camB");
|
||||
});
|
||||
|
||||
it("breaks ties by camera track order (first wins)", () => {
|
||||
const tied = [makeCameraTrackAt("camA", [3]), makeCameraTrackAt("camB", [3])];
|
||||
expect(getActiveCameraIdAtTime(tied, null, 3)).toBe("camA");
|
||||
it("breaks ties by track order (first clip ending wins)", () => {
|
||||
const tied = [
|
||||
{ cameraId: "camA", clips: [{ id: "a", start: 0, end: 3 }], keyframes: [] },
|
||||
{ cameraId: "camB", clips: [{ id: "b", start: 1, end: 3 }], keyframes: [] },
|
||||
];
|
||||
expect(getActiveCameraIdAtTime(noTransition, tied, null, 9)).toBe("camA");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Object3D, PerspectiveCamera } from "three";
|
||||
import { degreesRotationToRadians } from "../schema/directorProject";
|
||||
import type { DirectorObject } from "../schema/directorProject";
|
||||
import type { DirectorCameraShot, DirectorObject, SceneSettings } from "../schema/directorProject";
|
||||
import { evaluateCameraTrackAtTime, evaluateCameraViewAtTime, evaluateTrackAtTime } from "../schema/timeline";
|
||||
import type { CameraTimelineTrack, CameraTransition, TimelineTrack } from "../schema/timeline";
|
||||
import type { CameraTimelineTrack, CameraTransition, TimelineTrack, TransitionClip } from "../schema/timeline";
|
||||
import { evaluateTrajectoryMotionAtTime, hasTrajectoryMotion } from "../schema/trajectoryMotion";
|
||||
import { getCameraLookQuaternion, getCameraRenderQuaternion } from "../schema/cameraGeometry";
|
||||
import { getCameraLookQuaternion, getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot } from "../schema/cameraGeometry";
|
||||
|
||||
/** 人物轨迹驱动所需的工程上下文:对象列表 + 时间轴时长。 */
|
||||
export interface TimelinePlaybackContext {
|
||||
@@ -98,7 +98,10 @@ export function applyTimelineTransformsAtTime(
|
||||
*/
|
||||
export function applyActiveCameraViewAtTime({
|
||||
camera,
|
||||
transitionClips,
|
||||
cameraTracks,
|
||||
cameras,
|
||||
projectScene,
|
||||
activeCameraId,
|
||||
playhead,
|
||||
viewMode,
|
||||
@@ -107,7 +110,10 @@ export function applyActiveCameraViewAtTime({
|
||||
fallbackCameraId,
|
||||
}: {
|
||||
camera: PerspectiveCamera;
|
||||
transitionClips: TransitionClip[];
|
||||
cameraTracks: CameraTimelineTrack[];
|
||||
cameras?: DirectorCameraShot[];
|
||||
projectScene?: SceneSettings;
|
||||
activeCameraId: string | null;
|
||||
playhead: number;
|
||||
viewMode: "director" | "camera";
|
||||
@@ -120,8 +126,24 @@ export function applyActiveCameraViewAtTime({
|
||||
if (viewMode !== "camera") return false;
|
||||
if (!activeCameraId) return false;
|
||||
if (editingObjectId === activeCameraId) return false;
|
||||
// 含多机位切换过渡:切点附近把相机姿态从上一机位插值过渡到新机位(单相机即可)
|
||||
const view = evaluateCameraViewAtTime(cameraTracks, transition, fallbackCameraId ?? activeCameraId, playhead);
|
||||
|
||||
// 没有运镜关键帧的机位用其静态视角参与过渡,保证「机位间过渡」也能混合纯静态机位
|
||||
const getStaticPose = (cameraId: string) => {
|
||||
if (!cameras || !projectScene) return null;
|
||||
const shot = cameras.find((item) => item.id === cameraId);
|
||||
if (!shot) return null;
|
||||
return getWorldCameraViewSnapshot(getCameraViewSnapshotFromShot(shot), projectScene);
|
||||
};
|
||||
|
||||
// 含多机位切换过渡:过渡片段内把相机姿态从 from 插值过渡到 to(单相机即可)
|
||||
const view = evaluateCameraViewAtTime(
|
||||
transitionClips,
|
||||
cameraTracks,
|
||||
transition,
|
||||
fallbackCameraId ?? activeCameraId,
|
||||
playhead,
|
||||
getStaticPose
|
||||
);
|
||||
if (!view) return false;
|
||||
const value = view.pose;
|
||||
|
||||
|
||||
@@ -303,6 +303,9 @@ export interface DirectorCameraCapture {
|
||||
export interface DirectorCameraShot {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 机位在时间轴上的标识色(运镜片段条 / 折线 / 切换轨段都按它着色)。
|
||||
* 像角色一样持久化存进工程,保证刷新后颜色稳定、不随渲染顺序变化。 */
|
||||
color: string;
|
||||
fov: number;
|
||||
transform: DirectorTransform;
|
||||
targetMode: "manual" | "object";
|
||||
|
||||
@@ -31,11 +31,12 @@ import {
|
||||
updateTimelineDuration,
|
||||
updateTimelinePlaybackSettings,
|
||||
evaluateCameraViewAtTime,
|
||||
getCameraCutSegments,
|
||||
setCameraKeyframeTransition,
|
||||
getActiveCameraIdAtTime,
|
||||
getClipCameraAtTime,
|
||||
type CameraTimelineTrack,
|
||||
type CameraKeyframeValue,
|
||||
type CameraTransition,
|
||||
type TransitionClip,
|
||||
} from "./timeline";
|
||||
|
||||
const V0 = { position: [0, 0, 0] as [number, number, number], rotation: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number] };
|
||||
@@ -48,6 +49,7 @@ it("creates a default empty timeline with both track kinds and playback settings
|
||||
duration: DEFAULT_TIMELINE_DURATION,
|
||||
tracks: [],
|
||||
cameraTracks: [],
|
||||
transitionClips: [],
|
||||
fps: DEFAULT_TIMELINE_FPS,
|
||||
loop: false,
|
||||
cameraTransition: { type: "smooth", duration: 0.5 },
|
||||
@@ -76,7 +78,7 @@ it("returns null for an object track with no keyframes", () => {
|
||||
|
||||
// ---------- 相机插值 ----------
|
||||
it("lerps camera position/fov linearly and slerps the look angles between two keyframes", () => {
|
||||
const track = { cameraId: "cam_1", keyframes: [{ id: "k1", time: 0, value: CAM_A }, { id: "k2", time: 2, value: CAM_B }] };
|
||||
const track = { cameraId: "cam_1", clips: [], keyframes: [{ id: "k1", time: 0, value: CAM_A }, { id: "k2", time: 2, value: CAM_B }] };
|
||||
const value = evaluateCameraTrackAtTime(track, 1);
|
||||
|
||||
expect(value?.position).toEqual([5, 0, 0]);
|
||||
@@ -88,7 +90,7 @@ it("lerps camera position/fov linearly and slerps the look angles between two ke
|
||||
});
|
||||
|
||||
it("returns null for a camera track with no keyframes", () => {
|
||||
expect(evaluateCameraTrackAtTime({ cameraId: "cam_1", keyframes: [] }, 1)).toBeNull();
|
||||
expect(evaluateCameraTrackAtTime({ cameraId: "cam_1", clips: [], keyframes: [] }, 1)).toBeNull();
|
||||
});
|
||||
|
||||
// ---------- 排序 ----------
|
||||
@@ -138,7 +140,7 @@ it("updates an object keyframe value without changing time", () => {
|
||||
// ---------- 相机增删改 ----------
|
||||
it("upserts a camera keyframe into a new camera track", () => {
|
||||
const next = upsertCameraKeyframe(createDefaultTimeline(), "cam_1", 1, CAM_A, "ckf_1");
|
||||
expect(next.cameraTracks).toEqual([{ cameraId: "cam_1", keyframes: [{ id: "ckf_1", time: 1, value: CAM_A }] }]);
|
||||
expect(next.cameraTracks).toEqual([{ cameraId: "cam_1", clips: [], keyframes: [{ id: "ckf_1", time: 1, value: CAM_A }] }]);
|
||||
});
|
||||
|
||||
it("removes a camera keyframe and drops the empty camera track", () => {
|
||||
@@ -272,105 +274,88 @@ it("applies playback settings only for the fields being passed", () => {
|
||||
expect(patched.loop).toBe(false);
|
||||
});
|
||||
|
||||
describe("evaluateCameraViewAtTime (多机位切换过渡)", () => {
|
||||
describe("evaluateCameraViewAtTime (机位活跃片段 + 运镜(过渡)片段)", () => {
|
||||
const A: CameraKeyframeValue = { position: [0, 0, 0], lookRotation: [0, 0, 0], fov: 30 };
|
||||
const B: CameraKeyframeValue = { position: [10, 0, 0], lookRotation: [0, 0, 0], fov: 60 };
|
||||
// 机位活跃片段:camA 在 [0,2],camB 在 [2,5]
|
||||
const tracks: CameraTimelineTrack[] = [
|
||||
{ cameraId: "camA", keyframes: [{ id: "a", time: 0, value: A }] },
|
||||
{ cameraId: "camB", keyframes: [{ id: "b", time: 2, value: B }] },
|
||||
{ cameraId: "camA", clips: [{ id: "c1", start: 0, end: 2 }], keyframes: [{ id: "a", time: 0, value: A }] },
|
||||
{ cameraId: "camB", clips: [{ id: "c2", start: 2, end: 5 }], keyframes: [{ id: "b", time: 2, value: B }] },
|
||||
];
|
||||
const smooth: CameraTransition = { type: "smooth", duration: 1 };
|
||||
const cut: CameraTransition = { type: "cut", duration: 1 };
|
||||
// 运镜(过渡)片段:camA→camB 在 [2,3] 平滑
|
||||
const transitions: TransitionClip[] = [
|
||||
{ id: "t1", start: 2, end: 3, fromCameraId: "camA", toCameraId: "camB", type: "smooth" },
|
||||
];
|
||||
|
||||
it("blends the pose across the transition window (camera smooth motion)", () => {
|
||||
expect(evaluateCameraViewAtTime(tracks, smooth, null, 0)?.pose.position).toEqual([0, 0, 0]);
|
||||
// 切点处仍是上一机位姿态,保证与切点前连续
|
||||
expect(evaluateCameraViewAtTime(tracks, smooth, null, 2)?.pose.position).toEqual([0, 0, 0]);
|
||||
expect(evaluateCameraViewAtTime(transitions, tracks, smooth, null, 0)?.pose.position).toEqual([0, 0, 0]);
|
||||
// 过渡起点处仍是 from 机位姿态,保证与过渡前连续
|
||||
expect(evaluateCameraViewAtTime(transitions, tracks, smooth, null, 2)?.pose.position).toEqual([0, 0, 0]);
|
||||
// 过渡中点:A→B 在 easeInOutCubic(0.5)=0.5 处插值
|
||||
const mid = evaluateCameraViewAtTime(tracks, smooth, null, 2.5);
|
||||
const mid = evaluateCameraViewAtTime(transitions, tracks, smooth, null, 2.5);
|
||||
expect(mid?.pose.position).toEqual([5, 0, 0]);
|
||||
expect(mid?.pose.fov).toBe(45);
|
||||
// 过渡窗口结束后落到新机位姿态
|
||||
expect(evaluateCameraViewAtTime(tracks, smooth, null, 3)?.pose.position).toEqual([10, 0, 0]);
|
||||
expect(evaluateCameraViewAtTime(transitions, tracks, smooth, null, 3)?.pose.position).toEqual([10, 0, 0]);
|
||||
});
|
||||
|
||||
it("hard cut jumps straight to the new camera pose", () => {
|
||||
expect(evaluateCameraViewAtTime(tracks, cut, null, 2.5)?.pose.position).toEqual([10, 0, 0]);
|
||||
it("hard cut jumps straight to the new camera pose inside the clip", () => {
|
||||
const cutTransitions: TransitionClip[] = [
|
||||
{ id: "t1", start: 2, end: 2.1, fromCameraId: "camA", toCameraId: "camB", type: "cut" },
|
||||
];
|
||||
// 过渡片段内(cut 起点)直接落到 to 机位
|
||||
expect(evaluateCameraViewAtTime(cutTransitions, tracks, smooth, null, 2.05)?.pose.position).toEqual([10, 0, 0]);
|
||||
});
|
||||
|
||||
it("picks the active camera from clips outside transitions", () => {
|
||||
expect(evaluateCameraViewAtTime(transitions, tracks, smooth, null, 1)?.cameraId).toBe("camA");
|
||||
expect(evaluateCameraViewAtTime(transitions, tracks, smooth, null, 4)?.cameraId).toBe("camB");
|
||||
});
|
||||
|
||||
it("auto-resolves the from camera when fromCameraId is null", () => {
|
||||
const auto: TransitionClip[] = [
|
||||
{ id: "t1", start: 2, end: 3, fromCameraId: null, toCameraId: "camB", type: "smooth" },
|
||||
];
|
||||
const mid = evaluateCameraViewAtTime(auto, tracks, smooth, null, 2.5);
|
||||
expect(mid?.pose.position).toEqual([5, 0, 0]);
|
||||
});
|
||||
|
||||
it("freezes on the latest active camera during a gap (no transition, no clip)", () => {
|
||||
// t=6 两个 clips 都已结束,应冻结在最后结束的 camB
|
||||
expect(evaluateCameraViewAtTime(transitions, tracks, smooth, null, 6)?.cameraId).toBe("camB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCameraCutSegments (机位切换切点序列)", () => {
|
||||
const A: CameraKeyframeValue = { position: [0, 0, 0], lookRotation: [0, 0, 0], fov: 30 };
|
||||
const B: CameraKeyframeValue = { position: [10, 0, 0], lookRotation: [0, 0, 0], fov: 60 };
|
||||
describe("getActiveCameraIdAtTime / getClipCameraAtTime", () => {
|
||||
const tracks: CameraTimelineTrack[] = [
|
||||
{ cameraId: "camA", keyframes: [{ id: "a", time: 0, value: A }] },
|
||||
{ cameraId: "camB", keyframes: [{ id: "b", time: 2, value: B }] },
|
||||
{ cameraId: "camA", clips: [{ id: "c1", start: 0, end: 2 }], keyframes: [] },
|
||||
{ cameraId: "camB", clips: [{ id: "c2", start: 2, end: 5 }], keyframes: [] },
|
||||
];
|
||||
const transitions: TransitionClip[] = [
|
||||
{ id: "t1", start: 2, end: 3, fromCameraId: "camA", toCameraId: "camB", type: "smooth" },
|
||||
];
|
||||
|
||||
it("only marks cuts between placed camera segments (the initial camera at the earliest keyframe is not a switch)", () => {
|
||||
const cuts = getCameraCutSegments(tracks, { type: "smooth", duration: 0.5 }, null);
|
||||
// camA@0 是初始机位(其前无任何关键帧),不算切换;只有 camA→camB@2 记为一处切换
|
||||
expect(cuts).toHaveLength(1);
|
||||
expect(cuts[0].time).toBe(2);
|
||||
expect(cuts[0].toCameraId).toBe("camB");
|
||||
expect(cuts[0].fromCameraId).toBe("camA");
|
||||
// 无逐切点覆盖时回退全局默认
|
||||
expect(cuts[0].transition).toEqual({ type: "smooth", duration: 0.5 });
|
||||
it("returns the to-camera during a transition", () => {
|
||||
expect(getActiveCameraIdAtTime(transitions, tracks, null, 2.5)).toBe("camB");
|
||||
});
|
||||
|
||||
it("still suppresses the initial cut even when the active camera differs from the first keyframe", () => {
|
||||
const cuts = getCameraCutSegments(tracks, { type: "smooth", duration: 0.5 }, "camC");
|
||||
// fallback=camC 与首个关键帧 camA 不同,但首个关键帧之前无关键帧,仍不算切换
|
||||
expect(cuts).toHaveLength(1);
|
||||
expect(cuts[0].time).toBe(2);
|
||||
expect(cuts[0].fromCameraId).toBe("camA");
|
||||
it("returns the clip camera outside transitions", () => {
|
||||
expect(getActiveCameraIdAtTime(transitions, tracks, null, 1)).toBe("camA");
|
||||
expect(getActiveCameraIdAtTime(transitions, tracks, null, 4)).toBe("camB");
|
||||
});
|
||||
|
||||
it("prefers a per-cut transition override on the cut keyframe", () => {
|
||||
const overridden: CameraTimelineTrack[] = [
|
||||
{ cameraId: "camA", keyframes: [{ id: "a", time: 0, value: A }] },
|
||||
{ cameraId: "camB", keyframes: [{ id: "b", time: 2, value: B, transition: { type: "cut", duration: 1 } }] },
|
||||
];
|
||||
const cuts = getCameraCutSegments(overridden, { type: "smooth", duration: 0.5 }, null);
|
||||
expect(cuts).toHaveLength(1);
|
||||
expect(cuts[0].transition).toEqual({ type: "cut", duration: 1 });
|
||||
it("freezes to the last ended clip camera in a gap", () => {
|
||||
expect(getActiveCameraIdAtTime(transitions, tracks, null, 6)).toBe("camB");
|
||||
});
|
||||
|
||||
it("uses the latest-ended transition as the active camera after its end", () => {
|
||||
// 过渡 [2,3] 结束后 toCameraId(camB) 持续生效,直到下一个片段 / 过渡接管
|
||||
expect(getClipCameraAtTime(tracks, transitions, null, 4)).toBe("camB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateCameraViewAtTime per-cut override", () => {
|
||||
const A: CameraKeyframeValue = { position: [0, 0, 0], lookRotation: [0, 0, 0], fov: 30 };
|
||||
const B: CameraKeyframeValue = { position: [10, 0, 0], lookRotation: [0, 0, 0], fov: 60 };
|
||||
// 全局设为硬切,但 camB 的切点关键帧覆盖为平滑运镜
|
||||
const tracks: CameraTimelineTrack[] = [
|
||||
{ cameraId: "camA", keyframes: [{ id: "a", time: 0, value: A }] },
|
||||
{ cameraId: "camB", keyframes: [{ id: "b", time: 2, value: B, transition: { type: "smooth", duration: 1 } }] },
|
||||
];
|
||||
|
||||
it("uses the cut keyframe's transition instead of the global default", () => {
|
||||
// 全局是硬切:若用全局则 2.5s 直接是 B;用覆盖的平滑则应为 A→B 中点
|
||||
const view = evaluateCameraViewAtTime(tracks, { type: "cut", duration: 1 }, null, 2.5);
|
||||
expect(view?.pose.position).toEqual([5, 0, 0]);
|
||||
});
|
||||
|
||||
it("falls back to the global transition when the cut keyframe has no override", () => {
|
||||
const noOverride: CameraTimelineTrack[] = [
|
||||
{ cameraId: "camA", keyframes: [{ id: "a", time: 0, value: A }] },
|
||||
{ cameraId: "camB", keyframes: [{ id: "b", time: 2, value: B }] },
|
||||
];
|
||||
// 全局硬切 → 2.5s 直接到 B
|
||||
const view = evaluateCameraViewAtTime(noOverride, { type: "cut", duration: 1 }, null, 2.5);
|
||||
expect(view?.pose.position).toEqual([10, 0, 0]);
|
||||
});
|
||||
|
||||
it("does not apply a transition at the initial camera (no keyframe before the cut)", () => {
|
||||
// camA@0 是初始机位;即便 fallback 与 camA 不同,切点前无关键帧也不算切换,直接落位 camA
|
||||
const view = evaluateCameraViewAtTime(tracks, { type: "smooth", duration: 1 }, "camC", 0.5);
|
||||
expect(view?.pose.position).toEqual([0, 0, 0]);
|
||||
expect(view?.cameraId).toBe("camA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCameraKeyframeTransition", () => {
|
||||
describe("upsertCameraKeyframe creates a track with clips", () => {
|
||||
const timeline = createDefaultTimeline();
|
||||
const withTrack = upsertCameraKeyframe(timeline, "camX", 1, {
|
||||
position: [0, 0, 0],
|
||||
@@ -378,19 +363,10 @@ describe("setCameraKeyframeTransition", () => {
|
||||
fov: 30,
|
||||
});
|
||||
|
||||
it("sets a per-cut transition override on the keyframe", () => {
|
||||
const keyframeId = withTrack.cameraTracks[0].keyframes[0].id;
|
||||
const next = setCameraKeyframeTransition(withTrack, "camX", keyframeId, { type: "smooth", duration: 2 });
|
||||
expect(next.cameraTracks[0].keyframes[0].transition).toEqual({ type: "smooth", duration: 2 });
|
||||
// 不动其它轨道
|
||||
expect(next.cameraTracks[0].keyframes[0].time).toBe(1);
|
||||
});
|
||||
|
||||
it("clears the override (null) so it falls back to the global default", () => {
|
||||
const keyframeId = withTrack.cameraTracks[0].keyframes[0].id;
|
||||
const withOverride = setCameraKeyframeTransition(withTrack, "camX", keyframeId, { type: "smooth", duration: 2 });
|
||||
const cleared = setCameraKeyframeTransition(withOverride, "camX", keyframeId, null);
|
||||
expect(cleared.cameraTracks[0].keyframes[0].transition).toBeUndefined();
|
||||
it("initializes clips as an empty array so active-window evaluation never crashes", () => {
|
||||
expect(Array.isArray(withTrack.cameraTracks[0].clips)).toBe(true);
|
||||
expect(withTrack.cameraTracks[0].clips).toHaveLength(0);
|
||||
expect(withTrack.cameraTracks[0].keyframes[0].time).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -446,6 +422,7 @@ it("applies the starting keyframe's easing to the whole segment", () => {
|
||||
it("applies easing to camera segments (position + fov share the curve)", () => {
|
||||
const track = {
|
||||
cameraId: "cam_1",
|
||||
clips: [],
|
||||
keyframes: [
|
||||
{ id: "k1", time: 0, value: CAM_A, easing: "easeInOutCubic" as const },
|
||||
{ id: "k2", time: 2, value: CAM_B },
|
||||
|
||||
@@ -146,29 +146,50 @@ export interface CameraKeyframeValue {
|
||||
lookRotation: TransformTuple;
|
||||
fov: number;
|
||||
}
|
||||
/** 机位活跃片段:机位在 [start, end] 期间为「激活镜头」,时间轴按此自动切换机位。一台机位可有多段。 */
|
||||
export interface CameraActiveClip {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface CameraTimelineKeyframe {
|
||||
id: string;
|
||||
time: number;
|
||||
value: CameraKeyframeValue;
|
||||
/** 该关键帧「出去的那一段」使用的缓动曲线;不填或 "linear" 即线性插值 */
|
||||
easing?: EasingKind;
|
||||
/** 该关键帧成为激活机位时的切点过渡(硬切 / 平滑运镜);不填则退回时间轴全局默认 cameraTransition */
|
||||
transition?: CameraTransition;
|
||||
}
|
||||
export interface CameraTimelineTrack {
|
||||
cameraId: string; // 对应 DirectorCameraShot.id
|
||||
/** 机位活跃时间段:决定「谁在播」。可多段、可拖拽修剪(机位内运镜的载体) */
|
||||
clips: CameraActiveClip[];
|
||||
/** 机位内运镜关键帧:在活跃时间段内插值出相机姿态(位置 / 注视 / FOV) */
|
||||
keyframes: CameraTimelineKeyframe[];
|
||||
}
|
||||
|
||||
/** 运镜(过渡)片段:独立的「运镜轨道」上可拖拽的过渡片段,连接两个机位 */
|
||||
export interface TransitionClip {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
/** 过渡来源机位;null = 自动取 start 之前最近激活的机位 */
|
||||
fromCameraId: string | null;
|
||||
toCameraId: string;
|
||||
type: CameraTransitionType;
|
||||
}
|
||||
|
||||
export interface Timeline {
|
||||
duration: number; // 秒
|
||||
tracks: TimelineTrack[];
|
||||
cameraTracks: CameraTimelineTrack[];
|
||||
/** 运镜(过渡)轨:多机位切换的显式连接,可拖拽、可单独添加,连接由时间序唯一决定 */
|
||||
transitionClips: TransitionClip[];
|
||||
/** 每秒帧数:读数里的帧号与视频导出帧率都按它换算 */
|
||||
fps: number;
|
||||
/** 播放到末尾是否循环回起点 */
|
||||
loop: boolean;
|
||||
/** 多机位切换过渡(全局默认):硬切或相机平滑运镜 */
|
||||
/** 全局默认过渡(新建过渡片段的默认类型参考;具体时长由片段 [start,end] 决定) */
|
||||
cameraTransition: CameraTransition;
|
||||
}
|
||||
|
||||
@@ -239,6 +260,7 @@ export function createDefaultTimeline(): Timeline {
|
||||
duration: DEFAULT_TIMELINE_DURATION,
|
||||
tracks: [],
|
||||
cameraTracks: [],
|
||||
transitionClips: [],
|
||||
fps: DEFAULT_TIMELINE_FPS,
|
||||
loop: false,
|
||||
cameraTransition: DEFAULT_CAMERA_TRANSITION,
|
||||
@@ -357,101 +379,128 @@ export function evaluateCameraTrackAtTime(track: CameraTimelineTrack, time: numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个时刻应该显示哪个机位(多机位自动切换 / 自动切点):
|
||||
* 取所有相机轨道里「时间 ≤ t 的最后一帧」中时间最大者所属的那个机位。
|
||||
* 没有任何机位在 t 之前打过关键帧时,退回 fallback(通常是当前激活机位)。
|
||||
* 这样时间轴播放时,机位视角会按关键帧时间点自动切到对应机位。
|
||||
* 机位切换的「切点」类型(仅用于旧工程兼容迁移:把历史 cuts 转成 transitionClips)。新数据不再使用。
|
||||
*/
|
||||
export interface CameraSegment {
|
||||
cameraId: string | null;
|
||||
/** 该机位在 time 处成为激活机位的切点时间(即「≤ time 最近关键帧」的时间) */
|
||||
cutTime: number;
|
||||
export interface CameraCut {
|
||||
id: string;
|
||||
/** 切点时刻(秒,>= 0) */
|
||||
time: number;
|
||||
/** 切到哪台机位(DirectorCameraShot.id) */
|
||||
toCameraId: string;
|
||||
/** 该切点的过渡(硬切 / 平滑运镜);不填则退回全局默认 cameraTransition */
|
||||
transition: CameraTransition;
|
||||
}
|
||||
|
||||
/** 返回 time 处应显示的机位及其「切点」时间,供过渡计算使用 */
|
||||
export function getActiveCameraSegmentAtTime(
|
||||
cameraTracks: CameraTimelineTrack[],
|
||||
fallbackCameraId: string | null,
|
||||
/**
|
||||
* 找到 time 处生效的过渡片段:优先包含 time 的片段中 start 最晚者(片段重叠时后者胜出)。
|
||||
* 运镜(过渡)轨片段可拖拽,连接由时间序唯一决定。
|
||||
*/
|
||||
export function findActiveTransition(
|
||||
transitionClips: TransitionClip[],
|
||||
time: number
|
||||
): CameraSegment {
|
||||
let bestCameraId: string | null = null;
|
||||
let bestTime = -Infinity;
|
||||
|
||||
for (const track of cameraTracks) {
|
||||
if (track.keyframes.length === 0) continue;
|
||||
|
||||
// 取该机位在 t 之前(含)最近的一帧
|
||||
let latest = -Infinity;
|
||||
for (const keyframe of track.keyframes) {
|
||||
if (keyframe.time <= time && keyframe.time > latest) latest = keyframe.time;
|
||||
}
|
||||
|
||||
if (latest > bestTime) {
|
||||
bestTime = latest;
|
||||
bestCameraId = track.cameraId;
|
||||
): TransitionClip | null {
|
||||
let best: TransitionClip | null = null;
|
||||
let bestStart = -Infinity;
|
||||
for (const clip of transitionClips) {
|
||||
if (time >= clip.start && time <= clip.end && clip.start > bestStart) {
|
||||
bestStart = clip.start;
|
||||
best = clip;
|
||||
}
|
||||
}
|
||||
|
||||
return { cameraId: bestCameraId ?? fallbackCameraId, cutTime: bestTime };
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某机位的有效活跃窗口:
|
||||
* - 显式 clips 非空时直接用它们;
|
||||
* - clips 为空但有运镜关键帧时,按「首帧→末帧」自动推导一个兜底窗口,
|
||||
* 这样「只打关键帧」也能让该机位在其运镜区间生效,不会因缺 clips 而不在播。
|
||||
*/
|
||||
function getCameraActiveWindows(track: CameraTimelineTrack): { start: number; end: number }[] {
|
||||
if (track.clips.length > 0) return track.clips;
|
||||
if (track.keyframes.length > 0) {
|
||||
const times = track.keyframes.map((keyframe) => keyframe.time);
|
||||
return [{ start: Math.min(...times), end: Math.max(...times) }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 由「机位活跃片段(clips) + 运镜(过渡)片段结束」决定 time 处应显示的机位:
|
||||
* - 优先包含 time 的机位片段(重叠时取最晚开始者);
|
||||
* - 否则取在 time 之前结束、结束时刻最晚的事件(机位片段结束 或 过渡片段结束)所属机位;
|
||||
* 过渡片段结束后其 toCameraId 持续生效,直到下一个片段 / 过渡接管;
|
||||
* - 都没有则退回 fallback。机位切换完全按时间自动决定。
|
||||
*/
|
||||
export function getClipCameraAtTime(
|
||||
cameraTracks: CameraTimelineTrack[],
|
||||
transitionClips: TransitionClip[],
|
||||
fallbackCameraId: string | null,
|
||||
time: number
|
||||
): string | null {
|
||||
let containing: string | null = null;
|
||||
let containingStart = -Infinity;
|
||||
let latestEnd: string | null = null;
|
||||
let latestEndTime = -Infinity;
|
||||
for (const track of cameraTracks) {
|
||||
for (const clip of getCameraActiveWindows(track)) {
|
||||
if (time >= clip.start && time <= clip.end) {
|
||||
if (clip.start > containingStart) {
|
||||
containingStart = clip.start;
|
||||
containing = track.cameraId;
|
||||
}
|
||||
} else if (clip.end < time && clip.end > latestEndTime) {
|
||||
latestEndTime = clip.end;
|
||||
latestEnd = track.cameraId;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const tr of transitionClips) {
|
||||
if (tr.end < time && tr.end > latestEndTime) {
|
||||
latestEndTime = tr.end;
|
||||
latestEnd = tr.toCameraId;
|
||||
}
|
||||
}
|
||||
return containing ?? latestEnd ?? fallbackCameraId;
|
||||
}
|
||||
|
||||
/** 返回 time 处应显示的机位 id(多机位按时间自动切换)。 */
|
||||
export function getActiveCameraIdAtTime(
|
||||
transitionClips: TransitionClip[],
|
||||
cameraTracks: CameraTimelineTrack[],
|
||||
fallbackCameraId: string | null,
|
||||
time: number
|
||||
): string | null {
|
||||
return getActiveCameraSegmentAtTime(cameraTracks, fallbackCameraId, time).cameraId;
|
||||
const transition = findActiveTransition(transitionClips, time);
|
||||
if (transition) return transition.toCameraId;
|
||||
return getClipCameraAtTime(cameraTracks, transitionClips, fallbackCameraId, time);
|
||||
}
|
||||
|
||||
// 注:多机位切换现已由「机位活跃片段(clips) + 运镜(过渡)轨片段(transitionClips)」显式表达,
|
||||
// 不再需要基于 CameraCut 的切点序列展开(getCameraCutSegments 已移除)。
|
||||
|
||||
/**
|
||||
* 时间轴上「激活机位发生变化」的切点序列,用于把多机位切换过渡画在概览轨上:
|
||||
* 遍历所有相机轨道的关键帧(按时间排序),每当某关键帧使其所属机位成为新的激活机位(与上一刻不同),
|
||||
* 即记为一个切换点。该切换点的过渡优先取切点关键帧上的 transition,否则退回全局默认。
|
||||
* 注意:最早期关键帧建立的是时间轴「初始机位」,其前没有任何关键帧,不算一次切换(即便与当前激活机位不同),
|
||||
* 因此只有当该切点之前已存在其它关键帧时才记为切换,避免 0s 处出现一条无关的镜头切换。
|
||||
* 旧工程 / 没有显式运镜(过渡)轨时,从运镜关键帧反推出切换轨(兼容迁移用):
|
||||
* 取全局最早的关键帧作为「初始机位切点」,之后每个带显式 transition 的关键帧生成一个切点。
|
||||
*/
|
||||
export interface CameraCutSegment {
|
||||
/** 切换发生的时刻(= 切点关键帧的时间) */
|
||||
time: number;
|
||||
/** 切换前的激活机位(紧邻切点之前);时间轴开头无关键帧时为 fallback */
|
||||
fromCameraId: string | null;
|
||||
/** 切换后的激活机位(切点关键帧所属机位) */
|
||||
toCameraId: string;
|
||||
/** 切点关键帧(过渡就挂在它上面,编辑时按它定位) */
|
||||
keyframe: CameraTimelineKeyframe;
|
||||
/** 解析后的过渡:切点关键帧 transition ?? 全局默认 */
|
||||
transition: CameraTransition;
|
||||
}
|
||||
|
||||
export function getCameraCutSegments(
|
||||
export function deriveCameraCutsFromTracks(
|
||||
cameraTracks: CameraTimelineTrack[],
|
||||
globalTransition: CameraTransition,
|
||||
fallbackCameraId: string | null
|
||||
): CameraCutSegment[] {
|
||||
const items = cameraTracks
|
||||
.flatMap((track) => track.keyframes.map((kf) => ({ cameraId: track.cameraId, kf })))
|
||||
.sort((a, b) => a.kf.time - b.kf.time);
|
||||
const segments: CameraCutSegment[] = [];
|
||||
let prevCameraId = fallbackCameraId;
|
||||
for (const { cameraId, kf } of items) {
|
||||
const activeCameraId = getActiveCameraIdAtTime(cameraTracks, fallbackCameraId, kf.time);
|
||||
// 仅当该切点之前已存在其它关键帧时才记为一次切换:最早期关键帧(无前序关键帧)是时间轴初始机位,
|
||||
// 即便与当前激活机位不同也不算一次切换,不画在概览轨上(也避免 0s 处出现无关切换)
|
||||
const hasPriorKeyframe = cameraTracks.some((track) =>
|
||||
track.keyframes.some((k) => k.time < kf.time - 1e-6)
|
||||
);
|
||||
if (activeCameraId !== null && activeCameraId !== prevCameraId && hasPriorKeyframe) {
|
||||
segments.push({
|
||||
time: kf.time,
|
||||
fromCameraId: prevCameraId,
|
||||
toCameraId: activeCameraId,
|
||||
keyframe: kf,
|
||||
transition: kf.transition ? normalizeCameraTransition(kf.transition) : globalTransition,
|
||||
});
|
||||
}
|
||||
prevCameraId = activeCameraId;
|
||||
): CameraCut[] {
|
||||
const all = cameraTracks.flatMap((track) => track.keyframes.map((kf) => ({ cameraId: track.cameraId, kf })));
|
||||
if (all.length === 0) return [];
|
||||
const sorted = [...all].sort((a, b) => a.kf.time - b.kf.time);
|
||||
// 旧工程关键帧上已无「过渡覆盖」字段(过渡已移到 TransitionClip),故每个关键帧统一用全局默认过渡
|
||||
const cuts: CameraCut[] = sorted.map(({ cameraId, kf }) => ({
|
||||
id: createKeyframeId(),
|
||||
time: kf.time,
|
||||
toCameraId: cameraId,
|
||||
transition: normalizeCameraTransition(null),
|
||||
}));
|
||||
if (cuts.length === 0 && fallbackCameraId) {
|
||||
cuts.push({ id: createKeyframeId(), time: 0, toCameraId: fallbackCameraId, transition: normalizeCameraTransition(null) });
|
||||
}
|
||||
return segments;
|
||||
return cuts;
|
||||
}
|
||||
|
||||
export interface CameraViewAtTime {
|
||||
@@ -461,62 +510,57 @@ export interface CameraViewAtTime {
|
||||
|
||||
/**
|
||||
* 计算 time 处机位视角应呈现的相机姿态,含多机位切换过渡:
|
||||
* - 无过渡(硬切)或已过过渡窗口:直接用当前激活机位在 time 处的插值姿态;
|
||||
* - 平滑运镜下:在 [cutTime, cutTime + duration) 内把姿态从「上一机位在切点处的姿态」
|
||||
* 按缓动插值到「当前机位在 time 处的姿态」,形成相机平滑运镜过渡。
|
||||
* - 落在某个运镜(过渡)片段 [start,end] 内:把 fromCameraId → toCameraId 的姿态做混合
|
||||
* (cut = 起点即切换到 to;smooth = 在 [start,end] 内按缓动插值)。from 为 null 时自动取
|
||||
* start 之前最近激活的机位。
|
||||
* - 否则落在某机位活跃片段内:用该机位在 time 处的插值姿态。
|
||||
* - 否则(空隙):冻结保持在 time 之前最近激活的机位。
|
||||
* 机位没有运镜关键帧时,用 getStaticPose 取该机位的静态视角(保证过渡也能混合「无运镜」的机位)。
|
||||
* 单相机即可实现,预览与导出共用,无需双画面渲染。
|
||||
*/
|
||||
export function evaluateCameraViewAtTime(
|
||||
transitionClips: TransitionClip[],
|
||||
cameraTracks: CameraTimelineTrack[],
|
||||
transition: CameraTransition,
|
||||
globalTransition: CameraTransition,
|
||||
fallbackCameraId: string | null,
|
||||
time: number
|
||||
time: number,
|
||||
getStaticPose?: (cameraId: string) => CameraKeyframeValue | null
|
||||
): CameraViewAtTime | null {
|
||||
const segment = getActiveCameraSegmentAtTime(cameraTracks, fallbackCameraId, time);
|
||||
const incomingTrack = cameraTracks.find((item) => item.cameraId === segment.cameraId);
|
||||
if (!incomingTrack || incomingTrack.keyframes.length === 0) return null;
|
||||
const incomingPose = evaluateCameraTrackAtTime(incomingTrack, time);
|
||||
if (!incomingPose) return null;
|
||||
const getPose = (cameraId: string | null): CameraKeyframeValue | null => {
|
||||
if (!cameraId) return null;
|
||||
const track = cameraTracks.find((item) => item.cameraId === cameraId);
|
||||
if (track && track.keyframes.length > 0) {
|
||||
const pose = evaluateCameraTrackAtTime(track, time);
|
||||
if (pose) return pose;
|
||||
}
|
||||
return getStaticPose ? getStaticPose(cameraId) : null;
|
||||
};
|
||||
|
||||
// 切点之前没有任何关键帧:这是时间轴的「初始机位」,不算一次切换,直接落位(与概览轨一致,避免 0s 处出现无关切换)
|
||||
const hasPriorKeyframe = cameraTracks.some((track) =>
|
||||
track.keyframes.some((k) => k.time < segment.cutTime - 1e-6)
|
||||
);
|
||||
if (!hasPriorKeyframe) {
|
||||
return { cameraId: segment.cameraId, pose: incomingPose };
|
||||
const transition = findActiveTransition(transitionClips, time);
|
||||
if (transition) {
|
||||
// from 自动解析为「过渡开始前那一刻」正在显示的机位;用 start 之前一瞬(而非 start 本身,
|
||||
// 因为片段边界时间两端都含,start 处会被 to 机位的片段抢走),避免转场起点取到 to 机位
|
||||
const fromId =
|
||||
transition.fromCameraId ??
|
||||
getClipCameraAtTime(cameraTracks, transitionClips, fallbackCameraId, Math.max(transition.start - 1e-3, 0));
|
||||
const toId = transition.toCameraId;
|
||||
const toPose = getPose(toId);
|
||||
if (!toPose) return null;
|
||||
if (transition.type !== "smooth") {
|
||||
return { cameraId: toId, pose: toPose };
|
||||
}
|
||||
const span = transition.end - transition.start;
|
||||
if (span <= 0) return { cameraId: toId, pose: toPose };
|
||||
const progress = applyEasing("easeInOutCubic", (time - transition.start) / span);
|
||||
const fromPose = getPose(fromId);
|
||||
if (!fromPose) return { cameraId: toId, pose: toPose };
|
||||
return { cameraId: toId, pose: lerpCameraValue(fromPose, toPose, progress) };
|
||||
}
|
||||
|
||||
// 本切点的过渡:优先用切点关键帧上挂的逐个过渡,否则退回全局默认 cameraTransition
|
||||
const cutKeyframe = incomingTrack.keyframes.find(
|
||||
(kf) => Math.abs(kf.time - segment.cutTime) < KEYFRAME_TIME_TOLERANCE
|
||||
);
|
||||
const effectiveTransition = cutKeyframe?.transition
|
||||
? normalizeCameraTransition(cutKeyframe.transition)
|
||||
: transition;
|
||||
|
||||
if (effectiveTransition.type !== "smooth" || effectiveTransition.duration <= 0) {
|
||||
return { cameraId: segment.cameraId, pose: incomingPose };
|
||||
}
|
||||
|
||||
const elapsed = time - segment.cutTime;
|
||||
if (elapsed >= effectiveTransition.duration) {
|
||||
return { cameraId: segment.cameraId, pose: incomingPose };
|
||||
}
|
||||
|
||||
// 过渡窗口内:找到切点之前的上一机位,取其切点处的姿态作为过渡起点
|
||||
const prevSegment = getActiveCameraSegmentAtTime(cameraTracks, fallbackCameraId, segment.cutTime - 1e-4);
|
||||
if (!prevSegment.cameraId || prevSegment.cameraId === segment.cameraId) {
|
||||
return { cameraId: segment.cameraId, pose: incomingPose };
|
||||
}
|
||||
const prevTrack = cameraTracks.find((item) => item.cameraId === prevSegment.cameraId);
|
||||
if (!prevTrack || prevTrack.keyframes.length === 0) {
|
||||
return { cameraId: segment.cameraId, pose: incomingPose };
|
||||
}
|
||||
const outgoingPose = evaluateCameraTrackAtTime(prevTrack, segment.cutTime);
|
||||
if (!outgoingPose) return { cameraId: segment.cameraId, pose: incomingPose };
|
||||
|
||||
const progress = applyEasing("easeInOutCubic", elapsed / effectiveTransition.duration);
|
||||
return { cameraId: segment.cameraId, pose: lerpCameraValue(outgoingPose, incomingPose, progress) };
|
||||
const clipCameraId = getClipCameraAtTime(cameraTracks, transitionClips, fallbackCameraId, time);
|
||||
const pose = getPose(clipCameraId);
|
||||
if (clipCameraId && pose) return { cameraId: clipCameraId, pose };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===== 物体增删改 =====
|
||||
@@ -614,7 +658,7 @@ export function upsertCameraKeyframe(
|
||||
return { ...track, keyframes: sortKeyframes([...track.keyframes, { id: keyframeId, time: safeTime, value }]) };
|
||||
});
|
||||
if (touchedExisting || cameraTracks.some((t) => t.cameraId === cameraId)) return { ...timeline, cameraTracks };
|
||||
return { ...timeline, cameraTracks: [...timeline.cameraTracks, { cameraId, keyframes: [{ id: keyframeId, time: safeTime, value }] }] };
|
||||
return { ...timeline, cameraTracks: [...timeline.cameraTracks, { cameraId, clips: [], keyframes: [{ id: keyframeId, time: safeTime, value }] }] };
|
||||
}
|
||||
|
||||
export function removeCameraKeyframe(timeline: Timeline, cameraId: string, keyframeId: string): Timeline {
|
||||
@@ -669,29 +713,12 @@ export function updateCameraKeyframeEasing(
|
||||
};
|
||||
}
|
||||
|
||||
/** 设置 / 清除某个运镜关键帧上的「切点过渡」覆盖(transition 为 null = 清除,退回全局默认 cameraTransition) */
|
||||
export function setCameraKeyframeTransition(
|
||||
timeline: Timeline,
|
||||
cameraId: string,
|
||||
keyframeId: string,
|
||||
transition: CameraTransition | null
|
||||
): Timeline {
|
||||
return {
|
||||
...timeline,
|
||||
cameraTracks: timeline.cameraTracks.map((track) =>
|
||||
track.cameraId !== cameraId
|
||||
? track
|
||||
: {
|
||||
...track,
|
||||
keyframes: track.keyframes.map((kf) =>
|
||||
kf.id === keyframeId
|
||||
? { ...kf, transition: transition ? normalizeCameraTransition(transition) : undefined }
|
||||
: kf
|
||||
),
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
// ===== 运镜(过渡)片段时间 =====
|
||||
/** 运镜(过渡)片段里允许的最小时长(秒),保证可拖拽、视觉上可见 */
|
||||
export const TRANSITION_CLIP_MIN_DURATION = 0.1;
|
||||
|
||||
/** 机位活跃片段允许的最小时长(秒) */
|
||||
export const CAMERA_CLIP_MIN_DURATION = 0.1;
|
||||
|
||||
// ===== 运镜片段时间(时间轴上的「片段条 + 右端把手」) =====
|
||||
/** 运镜片段里相邻关键帧之间允许的最小时间间隔(秒),保证时间严格递增 */
|
||||
|
||||
@@ -51,21 +51,31 @@ import {
|
||||
updateCameraKeyframeTime as updateTimelineCameraKeyframeTime,
|
||||
updateCameraKeyframeValue as updateTimelineCameraKeyframeValue,
|
||||
updateCameraKeyframeEasing as updateTimelineCameraKeyframeEasing,
|
||||
setCameraKeyframeTransition as setTimelineCameraKeyframeTransition,
|
||||
setCameraKeyframeTimes as setTimelineCameraKeyframeTimes,
|
||||
updateTimelineDuration,
|
||||
updateTimelinePlaybackSettings,
|
||||
normalizeTimelineDuration,
|
||||
normalizeTimelineFps,
|
||||
normalizeCameraTransition,
|
||||
deriveCameraCutsFromTracks,
|
||||
TRANSITION_CLIP_MIN_DURATION,
|
||||
CAMERA_CLIP_MIN_DURATION,
|
||||
DEFAULT_TIMELINE_DURATION,
|
||||
DEFAULT_CAMERA_TRANSITION_DURATION,
|
||||
DEFAULT_CAMERA_TRANSITION,
|
||||
createKeyframeId,
|
||||
type KeyframeTransformValue,
|
||||
type CameraKeyframeValue,
|
||||
type CameraTransition,
|
||||
type CameraCut,
|
||||
type TransitionClip,
|
||||
type CameraActiveClip,
|
||||
type CameraTimelineTrack,
|
||||
type CameraTransitionType,
|
||||
type EasingKind,
|
||||
type Timeline,
|
||||
} from "../schema/timeline";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
import {
|
||||
TRAJECTORY_POINT_MIN_GAP,
|
||||
getTrajectoryPointTimes,
|
||||
@@ -284,7 +294,14 @@ export interface DirectorActions {
|
||||
updateCameraKeyframeTime: (cameraId: string, keyframeId: string, time: number) => void;
|
||||
updateCameraKeyframeValue: (cameraId: string, keyframeId: string, value: CameraKeyframeValue) => void;
|
||||
updateCameraKeyframeEasing: (cameraId: string, keyframeId: string, easing: EasingKind) => void;
|
||||
setCameraKeyframeTransition: (cameraId: string, keyframeId: string, transition: CameraTransition | null) => void;
|
||||
/** 机位活跃片段(机位轨道上的时间段):决定「谁在播」,可独立增删改、拖拽修剪 */
|
||||
addCameraClip: (cameraId: string, start: number, end: number) => string;
|
||||
updateCameraClip: (cameraId: string, clipId: string, patch: Partial<CameraActiveClip>) => void;
|
||||
removeCameraClip: (cameraId: string, clipId: string) => void;
|
||||
/** 运镜(过渡)轨片段:连接两个机位,可拖拽、单独添加、改前后机位 */
|
||||
addTransitionClip: (fromCameraId: string | null, toCameraId: string, type?: CameraTransitionType) => string;
|
||||
updateTransitionClip: (clipId: string, patch: Partial<TransitionClip>) => void;
|
||||
removeTransitionClip: (clipId: string) => void;
|
||||
/** 一次性覆盖整条运镜的所有关键帧时间(拖动「运镜时长」把手按比例缩放用) */
|
||||
setCameraKeyframeTimes: (cameraId: string, times: number[]) => void;
|
||||
applyCameraKeyframeToShot: (cameraId: string, value: CameraKeyframeValue) => void;
|
||||
@@ -338,6 +355,17 @@ const CHARACTER_COLOR_PALETTE = [
|
||||
"#00B8D9",
|
||||
"#FF7A45",
|
||||
];
|
||||
/** 机位标识色调色板:刻意避开角色调色板那 8 个色,保证多机位各自颜色醒目不混。 */
|
||||
const CAMERA_COLOR_PALETTE = [
|
||||
"#0CA678",
|
||||
"#7048E8",
|
||||
"#1098AD",
|
||||
"#E8590C",
|
||||
"#4263EB",
|
||||
"#82C91E",
|
||||
"#D6336C",
|
||||
"#AE3EC9",
|
||||
];
|
||||
const GEOMETRY_PRIMITIVE_COLOR = "#d7e7ff";
|
||||
const ADDED_MODEL_WORLD_SPACING = 1.25;
|
||||
const COPY_PASTE_POSITION_OFFSET = 0.6;
|
||||
@@ -553,7 +581,12 @@ function withTimeline(project: DirectorProject): DirectorProject {
|
||||
timeline: {
|
||||
duration: normalizeTimelineDuration(duration),
|
||||
tracks: timeline.tracks ?? [],
|
||||
cameraTracks: timeline.cameraTracks ?? [],
|
||||
cameraTracks: (timeline.cameraTracks ?? []).map((track) => ({
|
||||
...track,
|
||||
// 兼容:旧工程相机轨道可能没有 clips 字段,补上空数组,避免求值遍历崩溃
|
||||
clips: Array.isArray(track.clips) ? track.clips : [],
|
||||
})),
|
||||
transitionClips: timeline.transitionClips ?? [],
|
||||
fps: normalizeTimelineFps(timeline.fps),
|
||||
loop: timeline.loop === true,
|
||||
cameraTransition: normalizeCameraTransition(timeline.cameraTransition),
|
||||
@@ -664,8 +697,110 @@ function migrateCharacterMotionClips(object: DirectorObject): DirectorObject {
|
||||
return { ...object, characterRig: { ...rig, motionClips: clips } };
|
||||
}
|
||||
|
||||
/** 旧工程机位没有 color 字段:按调色板顺序补上,保证刷新后颜色稳定(与角色取色同理)。 */
|
||||
function withCameraColors(project: DirectorProject): DirectorProject {
|
||||
const cameras = project.cameras ?? [];
|
||||
if (!cameras.some((camera) => !camera.color)) return project;
|
||||
|
||||
let colorIndex = 0;
|
||||
const nextCameras = cameras.map((camera) => {
|
||||
if (camera.color) return camera;
|
||||
const color = CAMERA_COLOR_PALETTE[colorIndex % CAMERA_COLOR_PALETTE.length];
|
||||
colorIndex += 1;
|
||||
return { ...camera, color };
|
||||
});
|
||||
|
||||
return { ...project, cameras: nextCameras };
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧工程兼容迁移:把历史的「切换轨(cuts) / 关键帧过渡」转成新的
|
||||
* 「机位活跃片段(clips) + 运镜(过渡)轨片段(transitionClips)」模型:
|
||||
* - 已有 transitionClips 则原样保留;
|
||||
* - 否则把旧 cuts 逐个转成 transitionClip(from 自动、to=cut.toCameraId、时长=cut.transition.duration);
|
||||
* - 都没有 cuts 但有带过渡的关键帧,则先用 deriveCameraCutsFromTracks 兜底再转;
|
||||
* - 每台机位的 cameraTracks 补上 clips(无 clips 时按关键帧跨度生成一段,保证旧工程也能按时间切换)。
|
||||
*/
|
||||
function withTransitionClips(project: DirectorProject): DirectorProject {
|
||||
const timeline = project.timeline;
|
||||
if (!timeline) return project;
|
||||
|
||||
const cameraTracks = (timeline.cameraTracks ?? []).map((track) => {
|
||||
const clips =
|
||||
Array.isArray(track.clips) && track.clips.length > 0
|
||||
? track.clips
|
||||
: // 旧工程无 clips:用关键帧跨度生成一段活跃片段
|
||||
track.keyframes.length > 0
|
||||
? [
|
||||
{
|
||||
id: createKeyframeId(),
|
||||
start: track.keyframes[0].time,
|
||||
end: track.keyframes[track.keyframes.length - 1].time,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return { ...track, clips };
|
||||
});
|
||||
|
||||
let transitionClips = timeline.transitionClips ?? [];
|
||||
if (transitionClips.length === 0) {
|
||||
// 兼容旧工程:直接读原始 cuts 字段(新 Timeline 类型里已不存在,这里做一次宽容转换)
|
||||
const rawCuts = (timeline as Timeline & { cuts?: CameraCut[] }).cuts;
|
||||
const legacyCuts =
|
||||
(rawCuts && rawCuts.length > 0
|
||||
? rawCuts
|
||||
: deriveCameraCutsFromTracks(cameraTracks, project.activeCameraId)) ?? [];
|
||||
transitionClips = legacyCuts.map((cut) => ({
|
||||
id: cut.id,
|
||||
start: cut.time,
|
||||
end: cut.time + (cut.transition?.duration ?? DEFAULT_CAMERA_TRANSITION_DURATION),
|
||||
fromCameraId: null,
|
||||
toCameraId: cut.toCameraId,
|
||||
type: cut.transition?.type ?? "smooth",
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
timeline: { ...timeline, cameraTracks, transitionClips },
|
||||
};
|
||||
}
|
||||
|
||||
/** 确保某机位存在一条运镜轨道(含 clips / keyframes 空数组),没有则补上。 */
|
||||
function ensureCameraTrack(
|
||||
cameraTracks: CameraTimelineTrack[],
|
||||
cameraId: string
|
||||
): CameraTimelineTrack[] {
|
||||
if (cameraTracks.some((track) => track.cameraId === cameraId)) return cameraTracks;
|
||||
return [...cameraTracks, { cameraId, clips: [], keyframes: [] }];
|
||||
}
|
||||
|
||||
/** 在「运镜(过渡)轨」追加一段从 fromCameraId → toCameraId 的过渡片段(放在 playhead 处)。 */
|
||||
function appendTransitionClip(
|
||||
timeline: Timeline,
|
||||
fromCameraId: string,
|
||||
toCameraId: string,
|
||||
playhead: number,
|
||||
globalTransition: CameraTransition
|
||||
): TransitionClip[] {
|
||||
const start = Math.min(Math.max(playhead, 0), timeline.duration);
|
||||
const baseEnd = globalTransition.type === "cut" ? start : start + globalTransition.duration;
|
||||
const end = Math.max(baseEnd, start + TRANSITION_CLIP_MIN_DURATION);
|
||||
const clip: TransitionClip = {
|
||||
id: createKeyframeId(),
|
||||
start,
|
||||
end: Math.min(end, timeline.duration),
|
||||
fromCameraId,
|
||||
toCameraId,
|
||||
type: globalTransition.type,
|
||||
};
|
||||
return [...(timeline.transitionClips ?? []), clip];
|
||||
}
|
||||
|
||||
function migrateDirectorProject(project: DirectorProject): DirectorProject {
|
||||
const withTimelineProject = withSceneDefaults(withCameraLookRotations(withTimeline(project)));
|
||||
const withTimelineProject = withTransitionClips(
|
||||
withCameraColors(withSceneDefaults(withCameraLookRotations(withTimeline(project))))
|
||||
);
|
||||
return {
|
||||
...withTimelineProject,
|
||||
// 旧工程(v2 早期)没有 exportSettings,补默认值,避免读取时 undefined
|
||||
@@ -830,6 +965,7 @@ export function createDefaultDirectorProject({
|
||||
const camera: DirectorCameraShot = {
|
||||
id: "cam_1",
|
||||
name: formatSceneItemName("机位", 1),
|
||||
color: CAMERA_COLOR_PALETTE[0],
|
||||
fov: DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT.fov,
|
||||
transform: createTransform(getCameraRigPositionFromViewSnapshot(DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT)),
|
||||
targetMode: "manual",
|
||||
@@ -872,7 +1008,11 @@ export function createDefaultDirectorProject({
|
||||
cameras: [camera],
|
||||
activeCameraId: camera.id,
|
||||
panoramaAssetId: null,
|
||||
timeline: createDefaultTimeline(),
|
||||
timeline: {
|
||||
...createDefaultTimeline(),
|
||||
// 默认工程只有一个机位,无需过渡片段;机位片段(clips)空,靠 fallback 直接显示该机位
|
||||
transitionClips: [],
|
||||
},
|
||||
exportSettings: DEFAULT_EXPORT_SETTINGS,
|
||||
};
|
||||
}
|
||||
@@ -957,6 +1097,16 @@ function getNextCharacterColor(objects: DirectorObject[]) {
|
||||
return CHARACTER_COLOR_PALETTE[characterCount % CHARACTER_COLOR_PALETTE.length];
|
||||
}
|
||||
|
||||
/** 取下一个可用机位色:优先用调色板里尚未被占用的,全部占满后按数量循环(与角色取色同策略)。 */
|
||||
function getNextCameraColor(cameras: DirectorCameraShot[]): string {
|
||||
const usedColors = new Set(cameras.map((camera) => camera.color).filter(Boolean));
|
||||
const unusedColor = CAMERA_COLOR_PALETTE.find((color) => !usedColors.has(color));
|
||||
|
||||
if (unusedColor) return unusedColor;
|
||||
|
||||
return CAMERA_COLOR_PALETTE[cameras.length % CAMERA_COLOR_PALETTE.length];
|
||||
}
|
||||
|
||||
function getGeometryPrimitiveLabel(geometryType: GeometryPrimitiveType) {
|
||||
return GEOMETRY_PRIMITIVE_OPTIONS.find((option) => option.type === geometryType)?.label ?? "几何模型";
|
||||
}
|
||||
@@ -1352,9 +1502,16 @@ function deleteObjectsByIds(state: DirectorRuntimeState, objectIds: string[]): D
|
||||
const nextCameraTracks = state.project.timeline.cameraTracks.filter(
|
||||
(track) => !linkedCameraIds.has(track.cameraId)
|
||||
);
|
||||
// 运镜(过渡)轨:删掉的机位对应的过渡片段一并移除(from/to 任一指向被删机位都清掉)
|
||||
const nextTransitionClips = linkedCameraIds.size
|
||||
? state.project.timeline.transitionClips.filter(
|
||||
(clip) => !linkedCameraIds.has(clip.toCameraId) && !linkedCameraIds.has(clip.fromCameraId ?? "")
|
||||
)
|
||||
: state.project.timeline.transitionClips;
|
||||
const timelineUnchanged =
|
||||
nextTracks.length === state.project.timeline.tracks.length &&
|
||||
nextCameraTracks.length === state.project.timeline.cameraTracks.length;
|
||||
nextCameraTracks.length === state.project.timeline.cameraTracks.length &&
|
||||
nextTransitionClips.length === state.project.timeline.transitionClips.length;
|
||||
const remainingSelectedObjectIds = getOrderedSelectedObjectIds(state).filter((id) => !objectIdSet.has(id));
|
||||
const remainingSelectedCrowdId =
|
||||
state.selectedCrowdId && nextObjects.some((item) => item.crowdId === state.selectedCrowdId)
|
||||
@@ -1388,6 +1545,7 @@ function deleteObjectsByIds(state: DirectorRuntimeState, objectIds: string[]): D
|
||||
...state.project.timeline,
|
||||
tracks: nextTracks,
|
||||
cameraTracks: nextCameraTracks,
|
||||
transitionClips: nextTransitionClips,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1492,6 +1650,7 @@ function pasteClipboardEntries(state: DirectorRuntimeState): DirectorRuntimeStat
|
||||
const nextCamera: DirectorCameraShot = {
|
||||
...entry.camera,
|
||||
id: nextCameraId,
|
||||
color: getNextCameraColor(state.project.cameras),
|
||||
name: formatSceneItemName("机位", cameraIndex),
|
||||
transform: applyOffsetToTransform(entry.camera.transform, offset),
|
||||
// 注视是朝向角,平移机位不会改变它(锁物体的机位稍后按新位置重算)
|
||||
@@ -2918,6 +3077,7 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
const nextCamera: DirectorCameraShot = {
|
||||
id: cameraId,
|
||||
name: formatSceneItemName("机位", cameraIndex),
|
||||
color: getNextCameraColor(state.project.cameras),
|
||||
fov: snapshot?.fov ?? 50,
|
||||
transform,
|
||||
targetMode: "manual",
|
||||
@@ -2948,6 +3108,25 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
cameras: [...state.project.cameras, nextCamera],
|
||||
activeCameraId: cameraId,
|
||||
objects: [...state.project.objects, nextCameraObject],
|
||||
// 解耦后:新增机位时,若当前已有其它机位,就在「运镜(过渡)轨」加一段从当前机位到新机位的过渡片段,
|
||||
// 让多机位切换立刻可见、可拖拽;单台机位则无需过渡片段。
|
||||
timeline: {
|
||||
...state.project.timeline,
|
||||
transitionClips:
|
||||
state.project.cameras.length > 0
|
||||
? appendTransitionClip(
|
||||
state.project.timeline,
|
||||
state.project.activeCameraId ?? state.project.cameras[state.project.cameras.length - 1].id,
|
||||
cameraId,
|
||||
Math.min(
|
||||
Math.max(usePlaybackStore.getState().playhead, 0),
|
||||
state.project.timeline.duration
|
||||
),
|
||||
state.project.timeline.cameraTransition
|
||||
)
|
||||
: state.project.timeline.transitionClips ?? [],
|
||||
cameraTracks: ensureCameraTrack(state.project.timeline.cameraTracks, cameraId),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -3326,12 +3505,107 @@ export const useDirectorStore = create<DirectorStore>((set, get) => {
|
||||
timeline: updateTimelineCameraKeyframeEasing(state.project.timeline, cameraId, keyframeId, easing),
|
||||
},
|
||||
})),
|
||||
setCameraKeyframeTransition: (cameraId, keyframeId, transition) =>
|
||||
addCameraClip: (cameraId, start, end) => {
|
||||
const clipId = createKeyframeId();
|
||||
commitMutation((state) => {
|
||||
const clampStart = Math.min(Math.max(start, 0), state.project.timeline.duration);
|
||||
const clampEnd = Math.min(Math.max(end, clampStart + CAMERA_CLIP_MIN_DURATION), state.project.timeline.duration);
|
||||
const nextClips = Array.isArray(
|
||||
state.project.timeline.cameraTracks.find((t) => t.cameraId === cameraId)?.clips
|
||||
)
|
||||
? state.project.timeline.cameraTracks.find((t) => t.cameraId === cameraId)!.clips
|
||||
: [];
|
||||
const nextTracks = ensureCameraTrack(state.project.timeline.cameraTracks, cameraId).map((track) =>
|
||||
track.cameraId === cameraId
|
||||
? { ...track, clips: [...nextClips, { id: clipId, start: clampStart, end: clampEnd }] }
|
||||
: track
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
project: { ...state.project, timeline: { ...state.project.timeline, cameraTracks: nextTracks } },
|
||||
};
|
||||
});
|
||||
return clipId;
|
||||
},
|
||||
updateCameraClip: (cameraId, clipId, patch) =>
|
||||
commitMutation((state) => {
|
||||
const nextTracks = state.project.timeline.cameraTracks.map((track) => {
|
||||
if (track.cameraId !== cameraId) return track;
|
||||
return {
|
||||
...track,
|
||||
clips: track.clips.map((clip) => {
|
||||
if (clip.id !== clipId) return clip;
|
||||
const merged = { ...clip, ...patch };
|
||||
const start = Math.min(Math.max(merged.start, 0), state.project.timeline.duration);
|
||||
const end = Math.min(
|
||||
Math.max(merged.end, start + CAMERA_CLIP_MIN_DURATION),
|
||||
state.project.timeline.duration
|
||||
);
|
||||
return { ...merged, start, end };
|
||||
}),
|
||||
};
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
project: { ...state.project, timeline: { ...state.project.timeline, cameraTracks: nextTracks } },
|
||||
};
|
||||
}),
|
||||
removeCameraClip: (cameraId, clipId) =>
|
||||
commitMutation((state) => {
|
||||
const nextTracks = state.project.timeline.cameraTracks.map((track) =>
|
||||
track.cameraId === cameraId
|
||||
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
|
||||
: track
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
project: { ...state.project, timeline: { ...state.project.timeline, cameraTracks: nextTracks } },
|
||||
};
|
||||
}),
|
||||
addTransitionClip: (fromCameraId, toCameraId, type) => {
|
||||
const clipId = createKeyframeId();
|
||||
commitMutation((state) => {
|
||||
const transitionType = type ?? state.project.timeline.cameraTransition.type;
|
||||
const playhead = Math.min(Math.max(usePlaybackStore.getState().playhead, 0), state.project.timeline.duration);
|
||||
const baseEnd = transitionType === "cut" ? playhead : playhead + DEFAULT_CAMERA_TRANSITION_DURATION;
|
||||
const end = Math.min(Math.max(baseEnd, playhead + TRANSITION_CLIP_MIN_DURATION), state.project.timeline.duration);
|
||||
const nextClips = [
|
||||
...state.project.timeline.transitionClips,
|
||||
{ id: clipId, start: playhead, end, fromCameraId, toCameraId, type: transitionType },
|
||||
];
|
||||
return {
|
||||
...state,
|
||||
project: { ...state.project, timeline: { ...state.project.timeline, transitionClips: nextClips } },
|
||||
};
|
||||
});
|
||||
return clipId;
|
||||
},
|
||||
updateTransitionClip: (clipId, patch) =>
|
||||
commitMutation((state) => {
|
||||
const nextClips = state.project.timeline.transitionClips.map((clip) => {
|
||||
if (clip.id !== clipId) return clip;
|
||||
const merged = { ...clip, ...patch };
|
||||
const start = Math.min(Math.max(merged.start, 0), state.project.timeline.duration);
|
||||
const end = Math.min(
|
||||
Math.max(merged.end, start + TRANSITION_CLIP_MIN_DURATION),
|
||||
state.project.timeline.duration
|
||||
);
|
||||
return { ...merged, start, end };
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
project: { ...state.project, timeline: { ...state.project.timeline, transitionClips: nextClips } },
|
||||
};
|
||||
}),
|
||||
removeTransitionClip: (clipId) =>
|
||||
commitMutation((state) => ({
|
||||
...state,
|
||||
project: {
|
||||
...state.project,
|
||||
timeline: setTimelineCameraKeyframeTransition(state.project.timeline, cameraId, keyframeId, transition),
|
||||
timeline: {
|
||||
...state.project.timeline,
|
||||
transitionClips: state.project.timeline.transitionClips.filter((clip) => clip.id !== clipId),
|
||||
},
|
||||
},
|
||||
})),
|
||||
setCameraKeyframeTimes: (cameraId, times) =>
|
||||
|
||||
@@ -3832,45 +3832,6 @@ button.inspector-dropdown-option.is-selected {
|
||||
.timeline-transition-lane {
|
||||
position: relative;
|
||||
}
|
||||
.timeline-transition-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.timeline-transition-cut {
|
||||
width: 3px;
|
||||
margin-left: -1px;
|
||||
background: rgb(var(--accent-rgb));
|
||||
border-radius: 2px;
|
||||
}
|
||||
.timeline-transition-smooth {
|
||||
min-width: 10px;
|
||||
border: 1px solid rgb(var(--accent-rgb) / 0.5);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(var(--accent-rgb) / 0.06),
|
||||
rgb(var(--accent-rgb) / 0.3)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timeline-transition-marker.is-selected {
|
||||
outline: 2px solid rgb(var(--accent-rgb));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.timeline-transition-duration {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--text-rgb));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.timeline-transition-editor {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
@@ -3941,6 +3902,126 @@ button.inspector-dropdown-option.is-selected {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 切换轨:每个机位的时间片段,背景用该机位色,平滑过渡处叠加渐变把前后两机位连起来 */
|
||||
.timeline-cut-segment {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 7px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.18);
|
||||
}
|
||||
.timeline-cut-segment.is-selected {
|
||||
z-index: 5;
|
||||
box-shadow: 0 0 0 2px rgb(var(--text-rgb)), inset 0 0 0 1px rgb(0 0 0 / 0.18);
|
||||
}
|
||||
.timeline-cut-gradient {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.timeline-cut-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 0.45);
|
||||
}
|
||||
/* 两端切点编辑器:每个切点一节,含类型 / 时长 / 目标机位 */
|
||||
.timeline-transition-editor-section {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgb(var(--border-rgb) / 0.6);
|
||||
}
|
||||
.timeline-transition-editor-section:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
.timeline-transition-editor-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--text-dim-rgb));
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.timeline-transition-editor-delete {
|
||||
font-size: 11px;
|
||||
color: rgb(229 73 73);
|
||||
background: transparent;
|
||||
border: 1px solid rgb(229 73 73 / 0.5);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.timeline-transition-editor-delete:hover {
|
||||
background: rgb(229 73 73 / 0.15);
|
||||
}
|
||||
|
||||
/* 运镜(过渡)轨片段:与人物轨迹条 / 机位活跃片段共用同一套 .timeline-trajectory-bar 视觉
|
||||
(片段条 + 左右两端 .timeline-trajectory-trim 把手),配色走「来源→目标机位」渐变,
|
||||
标签用白色以保证在渐变上可读 */
|
||||
.timeline-clip-bar-transition .timeline-trajectory-bar-label {
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 0.55);
|
||||
}
|
||||
.timeline-clip-bar-transition .timeline-trajectory-trim {
|
||||
background: rgb(255 255 255 / 0.5);
|
||||
}
|
||||
.timeline-clip-bar-transition .timeline-trajectory-trim:hover,
|
||||
.timeline-clip-bar-transition .timeline-trajectory-trim:focus-visible,
|
||||
.timeline-clip-bar-transition.is-selected .timeline-trajectory-trim {
|
||||
background: #fff;
|
||||
}
|
||||
.timeline-clip-bar-transition.is-selected {
|
||||
border-color: rgb(255 255 255 / 0.85);
|
||||
box-shadow: 0 0 0 2px rgb(var(--text-rgb)), inset 0 0 0 1px rgb(0 0 0 / 0.18);
|
||||
}
|
||||
/* 占位(只读)的默认活跃窗口:虚线轮廓,提示「按运镜关键帧自动推导,点击可转为可编辑片段」 */
|
||||
.timeline-trajectory-bar.is-placeholder {
|
||||
cursor: pointer;
|
||||
border-style: dashed;
|
||||
background: rgb(var(--camera-color, 18 184 134) / 0.1);
|
||||
border-color: rgb(var(--camera-color, 18 184 134) / 0.45);
|
||||
box-shadow: none;
|
||||
}
|
||||
.timeline-trajectory-bar.is-placeholder .timeline-trajectory-bar-label {
|
||||
color: rgb(var(--camera-color, 18 184 134));
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* 轨道上的「添加」按钮(添加过渡片段 / 添加机位片段) */
|
||||
.timeline-track-add-button,
|
||||
.timeline-add-clip-button {
|
||||
margin: 4px 0;
|
||||
padding: 3px 8px;
|
||||
border: 1px dashed rgb(var(--border-rgb) / 0.7);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: rgb(var(--text-muted-rgb));
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
.timeline-track-add-button:hover,
|
||||
.timeline-add-clip-button:hover {
|
||||
border-color: rgb(var(--accent-rgb));
|
||||
color: rgb(var(--text-rgb));
|
||||
}
|
||||
/* 编辑器固定在车道左侧,避免跑出可视区 */
|
||||
.timeline-transition-editor {
|
||||
left: 8px;
|
||||
max-width: calc(100% - 16px);
|
||||
}
|
||||
|
||||
.timeline-settings-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -4695,7 +4776,7 @@ body.is-timeline-dragging * {
|
||||
bottom: 4px;
|
||||
left: 0;
|
||||
border: 1px solid rgb(var(--accent-rgb) / 0.4);
|
||||
border-radius: 5px;
|
||||
border-radius: 4px;
|
||||
background: rgb(var(--accent-rgb) / 0.12);
|
||||
/* 条本体的手势是「整段平移」,不是所在轨道的拖动排序(手势里已 stopPropagation) */
|
||||
cursor: grab;
|
||||
@@ -4723,21 +4804,21 @@ body.is-timeline-dragging * {
|
||||
平时只露一条淡淡的分隔色,选中 / hover 时才加重成明确的把手。 */
|
||||
.timeline-trajectory-trim {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
cursor: ew-resize;
|
||||
touch-action: none;
|
||||
background: rgb(var(--accent-rgb) / 0.45);
|
||||
transition: background-color 0.14s ease;
|
||||
}
|
||||
.timeline-trajectory-trim.is-pre {
|
||||
left: 0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
left: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.timeline-trajectory-trim.is-post {
|
||||
right: 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
right: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.timeline-trajectory-trim:hover,
|
||||
.timeline-trajectory-trim:focus-visible {
|
||||
@@ -4750,22 +4831,23 @@ body.is-timeline-dragging * {
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--panel-rgb) / 0.55);
|
||||
}
|
||||
/* 运镜片段条:和人物轨迹条同一套「片段条 + 左右两端把手」交互(拖左端修剪起点、拖右端修剪终点),
|
||||
只是区间是「第一个运镜关键帧 → 最后一个关键帧」,配色换成运镜绿以区分 */
|
||||
只是区间是「第一个运镜关键帧 → 最后一个关键帧」。颜色来自每个机位持久化的 --camera-color,
|
||||
不同机位自动呈现不同色,且刷新后稳定不变。 */
|
||||
.timeline-clip-bar-camera {
|
||||
border-color: rgb(18 184 134 / 0.42);
|
||||
background: rgb(18 184 134 / 0.12);
|
||||
border-color: rgb(var(--camera-color, 18 184 134) / 0.42);
|
||||
background: rgb(var(--camera-color, 18 184 134) / 0.12);
|
||||
}
|
||||
.timeline-clip-bar-camera .timeline-trajectory-bar-label {
|
||||
color: #12b886;
|
||||
color: rgb(var(--camera-color, 18 184 134));
|
||||
}
|
||||
/* 运镜条的把手跟着运镜绿走,hover / 聚焦 / 选中时加重 */
|
||||
/* 运镜条的把手跟着机位色走,hover / 聚焦 / 选中时加重 */
|
||||
.timeline-clip-bar-camera .timeline-trajectory-trim {
|
||||
background: rgb(18 184 134 / 0.45);
|
||||
background: rgb(var(--camera-color, 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;
|
||||
background: rgb(var(--camera-color, 18 184 134));
|
||||
}
|
||||
/* 轨迹条内的连线:把各轨迹点用直线连起来,直观呈现走位路径(viewBox 0~100 映射条内百分比,
|
||||
preserveAspectRatio=none 让它随条拉伸铺满;non-scaling-stroke 保证线宽不随缩放变形) */
|
||||
@@ -4779,7 +4861,7 @@ body.is-timeline-dragging * {
|
||||
}
|
||||
.timeline-trajectory-connector polyline {
|
||||
fill: none;
|
||||
stroke: rgb(var(--accent-rgb));
|
||||
stroke: rgb(var(--camera-color, var(--accent-rgb)));
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
@@ -4794,9 +4876,13 @@ body.is-timeline-dragging * {
|
||||
box-shadow: 0 0 0 1px rgb(var(--accent-rgb) / 0.45);
|
||||
}
|
||||
.timeline-clip-bar-camera.is-selected {
|
||||
border-color: rgb(18 184 134 / 0.85);
|
||||
background: rgb(18 184 134 / 0.2);
|
||||
box-shadow: 0 0 0 1px rgb(18 184 134 / 0.45);
|
||||
border-color: rgb(var(--camera-color, 18 184 134) / 0.85);
|
||||
background: rgb(var(--camera-color, 18 184 134) / 0.2);
|
||||
box-shadow: 0 0 0 1px rgb(var(--camera-color, 18 184 134) / 0.45);
|
||||
}
|
||||
/* 机位轨标签名用机位色,强化「每机位一色」的辨识(颜色来自持久化的 --camera-color) */
|
||||
.timeline-camera-track .timeline-track-name {
|
||||
color: rgb(var(--camera-color, 18 184 134));
|
||||
}
|
||||
/* 动作片段条:与轨迹条 / 运镜条同构(片段条 + 左右两端把手),配色换成动作橙以和前两者区分 */
|
||||
.timeline-clip-bar-motion {
|
||||
@@ -4890,7 +4976,7 @@ body.is-timeline-dragging * {
|
||||
transform: rotate(45deg) scale(1.15);
|
||||
}
|
||||
.timeline-keyframe-camera .timeline-keyframe-dot {
|
||||
background: #12b886;
|
||||
background: rgb(var(--camera-color, 18 184 134));
|
||||
}
|
||||
/* 运镜关键帧:左右拖动即调整该帧的时间(和轨迹关键帧一致的手势) */
|
||||
.timeline-keyframe-camera {
|
||||
|
||||
Reference in New Issue
Block a user