refactor(timeline): 解耦机位片段与运镜关键帧并新增过渡片段编辑

- 新增 TransitionClipEditor 与 CameraClipEditor,支持编辑过渡类型、时长及机位片段起止
- 抽出可复用的 TimelineClipDragHandle,支持拖拽、修剪、吸附与方向键微调
- 机位轨道改用显式活跃片段(CameraActiveClip),关键帧仅作只读占位推导
- 画布视锥线支持机位自定义颜色
- 视频导出时传入过渡片段、机位与场景参数以保持预览一致
- 更新 TimelinePanel 相关测试以覆盖新模型与交互
This commit is contained in:
2026-09-19 07:53:35 +08:00
parent 8d6c30b548
commit 91fcb6841a
12 changed files with 1453 additions and 606 deletions

View File

@@ -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],

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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>

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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;

View File

@@ -303,6 +303,9 @@ export interface DirectorCameraCapture {
export interface DirectorCameraShot {
id: string;
name: string;
/** 机位在时间轴上的标识色(运镜片段条 / 折线 / 切换轨段都按它着色)。
* 像角色一样持久化存进工程,保证刷新后颜色稳定、不随渲染顺序变化。 */
color: string;
fov: number;
transform: DirectorTransform;
targetMode: "manual" | "object";

View File

@@ -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 },

View File

@@ -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;
// ===== 运镜片段时间(时间轴上的「片段条 + 右端把手」) =====
/** 运镜片段里相邻关键帧之间允许的最小时间间隔(秒),保证时间严格递增 */

View File

@@ -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) =>

View File

@@ -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 {