diff --git a/src/editor/canvas/SceneRoot.rotation.test.ts b/src/editor/canvas/SceneRoot.rotation.test.ts index 873daa9..a2dc832 100644 --- a/src/editor/canvas/SceneRoot.rotation.test.ts +++ b/src/editor/canvas/SceneRoot.rotation.test.ts @@ -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], diff --git a/src/editor/io/videoExport.ts b/src/editor/io/videoExport.ts index b3358d8..f367451 100644 --- a/src/editor/io/videoExport.ts +++ b/src/editor/io/videoExport.ts @@ -231,6 +231,7 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { 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 { // 有运镜轨道时两边共用同一份插值(预览 / 导出一致);返回 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, diff --git a/src/editor/panels/TimelinePanel.test.tsx b/src/editor/panels/TimelinePanel.test.tsx index c31ac2b..c903418 100644 --- a/src/editor/panels/TimelinePanel.test.tsx +++ b/src/editor/panels/TimelinePanel.test.tsx @@ -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(); - const bar = container.querySelector(".timeline-clip-bar-camera")!; + // 仅打了运镜关键帧、尚未显式添加片段:自动推导一个只读占位活跃窗口(clips 兜底同样在求值层生效) + const bar = container.querySelector(".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(); + // 单关键帧无法构成区间,既不画占位条也不画片段 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(); // 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(); 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(); - 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(); - 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(); 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(); 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(".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", () => { diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx index 7c91345..1ee3460 100644 --- a/src/editor/panels/TimelinePanel.tsx +++ b/src/editor/panels/TimelinePanel.tsx @@ -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 ( +
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 && ( + + )} + {props.children} + {!props.placeholder && ( + + )} +
+ ); +} + +/** 运镜(过渡)片段编辑器:改前后机位、过渡类型、删除 */ +function TransitionClipEditor(props: { + clip: TransitionClip; + cameras: DirectorCameraShot[]; + duration: number; + onUpdate: (patch: Partial) => void; + onRemove: () => void; + onClose: () => void; +}) { + const cameraOptions = props.cameras.map((camera) => ({ value: camera.id, label: camera.name })); + const smooth = props.clip.type === "smooth"; + return ( +
+
+
连接前后机位
+
+ 来源 + props.onUpdate({ fromCameraId: value === "__auto__" ? null : value })} + /> +
+
+ 切到 + props.onUpdate({ toCameraId: value })} + /> +
+
+ 类型 + props.onUpdate({ type: value as CameraTransitionType })} + /> +
+
+ 时长 + + { + 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 }); + }} + /> + 秒 + +
+
+
+ + +
+
+ ); +} + +/** 机位活跃片段编辑器:改起止、删除 */ +function CameraClipEditor(props: { + clip: CameraActiveClip; + duration: number; + onUpdate: (patch: Partial) => void; + onRemove: () => void; + onClose: () => void; +}) { + const timeField = (label: string, value: number, onValue: (next: number) => void) => ( +
+ {label} + + { + const next = Math.min( + Math.max(Number(event.target.value) || 0, 0), + props.duration + ); + onValue(next); + }} + /> + 秒 + +
+ ); + return ( +
+
+
机位活跃片段
+ {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) }) + )} +
+
+ + +
+
+ ); +} + 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(null); - /** 当前在「机位切换」概览轨中选中的过渡(按切点关键帧 id 定位),用于弹出编辑器 */ - const [selectedCutKeyframeId, setSelectedCutKeyframeId] = useState(null); + /** 当前在「运镜(过渡)轨」中选中的过渡片段,用于弹出编辑器 */ + const [selectedTransitionClipId, setSelectedTransitionClipId] = useState(null); + /** 当前在机位轨道中选中的活跃片段(格式 cameraId:clipId),用于弹出编辑器 */ + const [selectedCameraClipKey, setSelectedCameraClipKey] = useState(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(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()); 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) { @@ -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) { 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 ( -
-
-
-
- {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 ( -
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" ? ( - {formatTime(cut.transition.duration)} - ) : null} -
- ); - })} - {selectedCut ? ( -
-
- 类型 - - setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, { - type: value as CameraTransitionType, - duration: selectedCut.transition.duration, - }) - } - /> -
-
- 时长 -
- - 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)) - ), - }) - } - /> - 秒 -
-
-
- - -
-
- ) : null} -
-
- ); - })()} + {/* 运镜(过渡)轨:独立的「运镜轨道」,可单独添加过渡片段连接两个机位; + 片段可拖拽移动、可修剪两端、可改前后机位、可删除。连接由时间序唯一决定。 */} +
+
+
+
+ {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 ( + 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} + > + {toName} + + ); + })} + {(() => { + const selected = timeline.transitionClips.find((item) => item.id === selectedTransitionClipId) ?? null; + if (!selected) return null; + return ( + updateTransitionClip(selected.id, patch)} + onRemove={() => { + removeTransitionClip(selected.id); + setSelectedTransitionClipId(null); + }} + onClose={() => setSelectedTransitionClipId(null)} + /> + ); + })()} +
+
{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} >
@@ -2074,67 +2450,115 @@ export function TimelinePanel() { {trackName} {track.keyframes.length}
- {/* 运镜轨道头部车道:展开时放「运镜片段条」,折叠时放一行摘要(与角色轨道头部一致) */} + {/* 运镜轨道头部车道:展开时放「机位活跃片段条」(决定谁在播,可修剪 / 添加),折叠时放摘要 */}
{collapsed ? ( - {track.keyframes.length} 个运镜关键帧 - ) : cameraTimes.length >= 2 && duration > 0 ? ( -
- {formatTime(clipDuration)} - {/* 左端把手 = 修剪起点:末帧不动,首帧推后,各关键帧按比例缩放 */} - handleTrimStartKeyDown(event, clipOwner, cameraTimes, clipStart)} - {...getTimeDragProps(clipOwner, "trimStart", 0, cameraTimes, clipStart)} - /> - {/* 右端把手 = 修剪终点:首帧不动,末帧伸缩 */} - - handleTimeHandleKeyDown(event, clipOwner, cameraTimes, clipStart) - } - {...getTimeDragProps( - clipOwner, - "duration", - cameraTimes.length - 1, - cameraTimes, - clipStart - )} - /> -
- ) : null} + + {track.clips.length} 段活跃 · {track.keyframes.length} 个运镜关键帧 + + ) : track.clips.length === 0 ? ( + track.keyframes.length >= 2 ? ( + // 仅打了运镜关键帧、尚未显式添加片段:按「首帧→末帧」自动推导一个只读占位活跃窗口, + // 点击即转为可编辑的机位活跃片段;求值层 getClipCameraAtTime 也会同等兜底,保证「打关键帧即生效」 + (() => { + const pStart = Math.min(...cameraTimes); + const pEnd = Math.max(...cameraTimes); + return ( + { + const id = addCameraClip(track.cameraId, pStart, pEnd); + setSelectedCameraClipKey(`${track.cameraId}:${id}`); + }} + > + {formatTime(pEnd - pStart)} + + ); + })() + ) : ( + + ) + ) : ( + <> + {track.clips.map((clip) => { + const isSelected = selectedCameraClipKey === `${track.cameraId}:${clip.id}`; + return ( + + 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} + > + {formatTime(clip.end - clip.start)} + + ); + })} + {(() => { + 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 ( + updateCameraClip(track.cameraId, sel.id, patch)} + onRemove={() => { + removeCameraClip(track.cameraId, sel.id); + setSelectedCameraClipKey(null); + }} + onClose={() => setSelectedCameraClipKey(null)} + /> + ); + })()} + + )}
diff --git a/src/editor/runtime/ActiveCameraPlaybackSync.tsx b/src/editor/runtime/ActiveCameraPlaybackSync.tsx index 7276cad..69e842a 100644 --- a/src/editor/runtime/ActiveCameraPlaybackSync.tsx +++ b/src/editor/runtime/ActiveCameraPlaybackSync.tsx @@ -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, diff --git a/src/editor/runtime/playbackTransforms.test.ts b/src/editor/runtime/playbackTransforms.test.ts index 4eb3320..8bbc134 100644 --- a/src/editor/runtime/playbackTransforms.test.ts +++ b/src/editor/runtime/playbackTransforms.test.ts @@ -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"); }); }); diff --git a/src/editor/runtime/playbackTransforms.ts b/src/editor/runtime/playbackTransforms.ts index 3a44bcd..27c756d 100644 --- a/src/editor/runtime/playbackTransforms.ts +++ b/src/editor/runtime/playbackTransforms.ts @@ -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; diff --git a/src/editor/schema/directorProject.ts b/src/editor/schema/directorProject.ts index 8bd24a9..85317f8 100644 --- a/src/editor/schema/directorProject.ts +++ b/src/editor/schema/directorProject.ts @@ -303,6 +303,9 @@ export interface DirectorCameraCapture { export interface DirectorCameraShot { id: string; name: string; + /** 机位在时间轴上的标识色(运镜片段条 / 折线 / 切换轨段都按它着色)。 + * 像角色一样持久化存进工程,保证刷新后颜色稳定、不随渲染顺序变化。 */ + color: string; fov: number; transform: DirectorTransform; targetMode: "manual" | "object"; diff --git a/src/editor/schema/timeline.test.ts b/src/editor/schema/timeline.test.ts index 5be6baf..d075e2e 100644 --- a/src/editor/schema/timeline.test.ts +++ b/src/editor/schema/timeline.test.ts @@ -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 }, diff --git a/src/editor/schema/timeline.ts b/src/editor/schema/timeline.ts index 50a7b81..39157d4 100644 --- a/src/editor/schema/timeline.ts +++ b/src/editor/schema/timeline.ts @@ -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; // ===== 运镜片段时间(时间轴上的「片段条 + 右端把手」) ===== /** 运镜片段里相邻关键帧之间允许的最小时间间隔(秒),保证时间严格递增 */ diff --git a/src/editor/store/directorStore.ts b/src/editor/store/directorStore.ts index 1245bea..93e1ece 100644 --- a/src/editor/store/directorStore.ts +++ b/src/editor/store/directorStore.ts @@ -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) => void; + removeCameraClip: (cameraId: string, clipId: string) => void; + /** 运镜(过渡)轨片段:连接两个机位,可拖拽、单独添加、改前后机位 */ + addTransitionClip: (fromCameraId: string | null, toCameraId: string, type?: CameraTransitionType) => string; + updateTransitionClip: (clipId: string, patch: Partial) => 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((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((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((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) => diff --git a/src/styles/index.css b/src/styles/index.css index 1c5a3c4..2d4c6b5 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -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 {