diff --git a/src/editor/canvas/ViewportToolbar.test.tsx b/src/editor/canvas/ViewportToolbar.test.tsx index 019c136..2a42446 100644 --- a/src/editor/canvas/ViewportToolbar.test.tsx +++ b/src/editor/canvas/ViewportToolbar.test.tsx @@ -63,10 +63,10 @@ it("renders the viewport capsule as project icon-system buttons", () => { "旋转", "缩放", "添加角色", + "添加机位", "导入全景图", "导入本地模型", "模型库", - "添加机位", "选择画幅比例", "当前视角截图", "四方位截图", @@ -107,8 +107,8 @@ it("renders custom hover labels instead of native title tooltips", () => { it("uses the requested viewport toolbar SVG icons for camera and capture actions", () => { render(); - expect(screen.getByRole("button", { name: "添加机位" }).querySelector("svg")).toHaveClass("lucide-video"); - expect(screen.getByRole("button", { name: "当前视角截图" }).querySelector("svg")).toHaveClass("lucide-camera"); + expect(screen.getByRole("button", { name: "添加机位" }).querySelector("svg")).toHaveClass("lucide-camera"); + expect(screen.getByRole("button", { name: "当前视角截图" }).querySelector("svg")).toHaveClass("lucide-scan-line"); expect(screen.getByRole("button", { name: "四方位截图" }).querySelector("svg")).toHaveClass("lucide-grid2x2"); expect(screen.getByRole("button", { name: "十二方位截图" }).querySelector("svg")).toHaveClass("lucide-grid3x3"); }); diff --git a/src/editor/canvas/ViewportToolbar.tsx b/src/editor/canvas/ViewportToolbar.tsx index 7a586f6..3e354f4 100644 --- a/src/editor/canvas/ViewportToolbar.tsx +++ b/src/editor/canvas/ViewportToolbar.tsx @@ -12,11 +12,10 @@ import { Boxes, Camera, ChevronRight, - Circle, Expand, Grid2X2, Grid3X3, - Image, + ScanLine, ImagePlus, Move3D, Plus, @@ -24,7 +23,6 @@ import { Rotate3D, Route, Scale3D, - Square, Trash2, UserPlus, Video, @@ -499,6 +497,7 @@ export function ViewportToolbar({ { label: "移动", icon: Move3D, mode: "translate", onClick: () => selectTransformMode("translate") }, { label: "旋转", icon: Rotate3D, mode: "rotate", onClick: () => selectTransformMode("rotate") }, { label: "缩放", icon: Scale3D, mode: "scale", onClick: () => selectTransformMode("scale") }, + { label: "添加机位", icon: Camera, onClick: addCameraFromViewport }, { label: "导入全景图", icon: ImagePlus, onClick: () => panoramaInputRef.current?.click() }, { label: "导入本地模型", @@ -508,9 +507,8 @@ export function ViewportToolbar({ }, }, { label: "模型库", icon: Boxes, onClick: toggleModelLibrary }, - { label: "添加机位", icon: Video, onClick: addCameraFromViewport }, { label: "选择画幅比例", icon: Ratio, onClick: toggleAspectRatioPanel }, - { label: "当前视角截图", icon: Camera, onClick: () => void handleCapture("current") }, + { label: "当前视角截图", icon: ScanLine, onClick: () => void handleCapture("current") }, { label: "四方位截图", icon: Grid2X2, onClick: () => void handleCapture("four") }, { label: "十二方位截图", icon: Grid3X3, onClick: () => void handleCapture("twelve") }, { label: "全屏", icon: Expand, onClick: toggleViewportPanelsCollapsed }, @@ -529,7 +527,7 @@ export function ViewportToolbar({ }, { label: "录制", - icon: timelineVisible ? Square : Circle, + icon: Video, pressed: timelineVisible, className: timelineVisible ? "is-recording" : undefined, title: timelineVisible ? "隐藏时间轴 · 录制中按 K 打关键帧" : "显示时间轴(录制)", diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx index f8a8612..7c91345 100644 --- a/src/editor/panels/TimelinePanel.tsx +++ b/src/editor/panels/TimelinePanel.tsx @@ -21,6 +21,7 @@ import { Play, Plus, RefreshCw, + Repeat, Settings2, SkipBack, SkipForward, @@ -49,7 +50,9 @@ import { scaleCameraKeyframeTimes, EASING_KINDS, EASING_LABELS, + getCameraCutSegments, type CameraKeyframeValue, + type CameraTransition, type CameraTransitionType, type EasingKind, type KeyframeTransformValue, @@ -331,6 +334,7 @@ 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 setCameraKeyframeTimes = useDirectorStore((state) => state.setCameraKeyframeTimes); const applyCameraKeyframeToShot = useDirectorStore((state) => state.applyCameraKeyframeToShot); const setActiveCamera = useDirectorStore((state) => state.setActiveCamera); @@ -400,6 +404,19 @@ export function TimelinePanel() { // 轨道拖拽:生命周期(谁在拖 → 高亮 / 毛玻璃降级)走 React state,一次拖拽只重渲染两次; // 高频的落点线则完全不经过 React(ref + transform),详见 getTrackDragProps。 const [draggingTrackKey, setDraggingTrackKey] = useState(null); + /** 当前在「机位切换」概览轨中选中的过渡(按切点关键帧 id 定位),用于弹出编辑器 */ + const [selectedCutKeyframeId, setSelectedCutKeyframeId] = useState(null); + useEffect(() => { + if (!selectedCutKeyframeId) 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); + } + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, [selectedCutKeyframeId]); const dropTargetRef = useRef<{ key: string; position: TimelineTrackDropPosition } | null>(null); const dropIndicatorRef = useRef(null); /** 吸附参考线:拖动片段 / 关键帧时,边缘吸到其它片段边缘或播放头处显示一条高亮竖线 */ @@ -1882,6 +1899,143 @@ 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.cameraTracks.map((track) => { const shot = project.cameras.find((c) => c.id === track.cameraId); const trackName = shot?.name ?? track.cameraId; diff --git a/src/editor/runtime/GltfCharacterModel.tsx b/src/editor/runtime/GltfCharacterModel.tsx index ae23c4d..95356fb 100644 --- a/src/editor/runtime/GltfCharacterModel.tsx +++ b/src/editor/runtime/GltfCharacterModel.tsx @@ -84,6 +84,29 @@ export function GltfCharacterModel({ const normalization = useMemo(() => computeNormalization(scene), [scene]); + // 标签锚点高度:只用「静止姿态」量一次,依赖里不含 motionTime/controlsWithMotion。 + // 动画中骨骼摆动会改变包围盒高度,若每帧重算会让标签锚点跟着上下抖动。 + // 本 effect 定义在动画 effect 之前,量完把静止态留给紧随其后的动画 effect 还原成当前姿态。 + useLayoutEffect(() => { + if (rigType) { + applyHumanoidRestPoseAndRig(scene, { + controls: {}, + restPose, + rigType, + }); + } + + const bounds = new Box3().setFromObject(scene); + const size = bounds.getSize(new Vector3()); + const maxDimension = Math.max(size.x, size.y, size.z, 0.0001); + const scaledTop = (bounds.max.y - bounds.min.y) / maxDimension * IMPORTED_MODEL_TARGET_MAX_SIZE; + const labelAnchorY = scaledTop + VIEWPORT_OBJECT_LABEL_VERTICAL_GAP; + + if (Number.isFinite(labelAnchorY)) { + onLabelAnchorYChange?.(Number(labelAnchorY.toFixed(4))); + } + }, [onLabelAnchorYChange, restPose, rigType, scene]); + useLayoutEffect(() => { scene.traverse((object) => { object.frustumCulled = false; @@ -100,17 +123,7 @@ export function GltfCharacterModel({ rigType, }); } - - const bounds = new Box3().setFromObject(scene); - const size = bounds.getSize(new Vector3()); - const maxDimension = Math.max(size.x, size.y, size.z, 0.0001); - const scaledTop = (bounds.max.y - bounds.min.y) / maxDimension * IMPORTED_MODEL_TARGET_MAX_SIZE; - const labelAnchorY = scaledTop + VIEWPORT_OBJECT_LABEL_VERTICAL_GAP; - - if (Number.isFinite(labelAnchorY)) { - onLabelAnchorYChange?.(Number(labelAnchorY.toFixed(4))); - } - }, [controlsWithMotion, onLabelAnchorYChange, restPose, rigType, scene]); + }, [controlsWithMotion, restPose, rigType, scene]); return ( diff --git a/src/editor/runtime/UE4MannequinModel.tsx b/src/editor/runtime/UE4MannequinModel.tsx index 4ddf3fc..32258f5 100644 --- a/src/editor/runtime/UE4MannequinModel.tsx +++ b/src/editor/runtime/UE4MannequinModel.tsx @@ -162,14 +162,12 @@ export function UE4MannequinModel({ [rigControls, rigState?.motion, motionTime, characterId, motionStartTime, motionEndTime] ); + // 标签锚点高度:只用「静止姿态 + 静止落地」量一次,且依赖里不含 motionTime/controlsWithMotion, + // 否则走/跑时脚底下陷会让 alignUE4MannequinToGround 每帧把整模型上下抬以贴地, + // 包围盒上沿随之浮动 → 标签锚点 Y 跟着剧烈抖动。本 effect 定义在动画 effect 之前, + // 量完把静止态留给紧随其后的动画 effect 还原成当前姿态。 useLayoutEffect(() => { - isolateAndTintUE4MannequinMaterials(scene, color); - - applyUE4RestPoseAndRig(scene, { - bodyType, - controls: controlsWithMotion, - restPose, - }); + applyUE4RestPoseAndRig(scene, { bodyType, controls: {}, restPose }); alignUE4MannequinToGround(scene); const modelRoot = scene.parent ?? scene; @@ -179,7 +177,18 @@ export function UE4MannequinModel({ if (Number.isFinite(labelAnchorY)) { onLabelAnchorYChange?.(Number(labelAnchorY.toFixed(4))); } - }, [bodyType, color, controlsWithMotion, onLabelAnchorYChange, restPose, scene]); + }, [bodyType, onLabelAnchorYChange, restPose, scene]); + + useLayoutEffect(() => { + isolateAndTintUE4MannequinMaterials(scene, color); + + applyUE4RestPoseAndRig(scene, { + bodyType, + controls: controlsWithMotion, + restPose, + }); + alignUE4MannequinToGround(scene); + }, [bodyType, color, controlsWithMotion, restPose, scene]); return ( diff --git a/src/editor/schema/timeline.test.ts b/src/editor/schema/timeline.test.ts index 32d466b..5be6baf 100644 --- a/src/editor/schema/timeline.test.ts +++ b/src/editor/schema/timeline.test.ts @@ -31,6 +31,8 @@ import { updateTimelineDuration, updateTimelinePlaybackSettings, evaluateCameraViewAtTime, + getCameraCutSegments, + setCameraKeyframeTransition, type CameraTimelineTrack, type CameraKeyframeValue, type CameraTransition, @@ -297,6 +299,101 @@ describe("evaluateCameraViewAtTime (多机位切换过渡)", () => { }); }); +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 }; + const tracks: CameraTimelineTrack[] = [ + { cameraId: "camA", keyframes: [{ id: "a", time: 0, value: A }] }, + { cameraId: "camB", keyframes: [{ id: "b", time: 2, value: B }] }, + ]; + + 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("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("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 }); + }); +}); + +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", () => { + const timeline = createDefaultTimeline(); + const withTrack = upsertCameraKeyframe(timeline, "camX", 1, { + position: [0, 0, 0], + lookRotation: [0, 0, 0], + 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("exposes a fixed easing list that starts with linear", () => { expect(EASING_KINDS[0]).toBe("linear"); diff --git a/src/editor/schema/timeline.ts b/src/editor/schema/timeline.ts index 8ed37bb..50a7b81 100644 --- a/src/editor/schema/timeline.ts +++ b/src/editor/schema/timeline.ts @@ -152,6 +152,8 @@ export interface CameraTimelineKeyframe { value: CameraKeyframeValue; /** 该关键帧「出去的那一段」使用的缓动曲线;不填或 "linear" 即线性插值 */ easing?: EasingKind; + /** 该关键帧成为激活机位时的切点过渡(硬切 / 平滑运镜);不填则退回时间轴全局默认 cameraTransition */ + transition?: CameraTransition; } export interface CameraTimelineTrack { cameraId: string; // 对应 DirectorCameraShot.id @@ -401,6 +403,57 @@ export function getActiveCameraIdAtTime( return getActiveCameraSegmentAtTime(cameraTracks, fallbackCameraId, time).cameraId; } +/** + * 时间轴上「激活机位发生变化」的切点序列,用于把多机位切换过渡画在概览轨上: + * 遍历所有相机轨道的关键帧(按时间排序),每当某关键帧使其所属机位成为新的激活机位(与上一刻不同), + * 即记为一个切换点。该切换点的过渡优先取切点关键帧上的 transition,否则退回全局默认。 + * 注意:最早期关键帧建立的是时间轴「初始机位」,其前没有任何关键帧,不算一次切换(即便与当前激活机位不同), + * 因此只有当该切点之前已存在其它关键帧时才记为切换,避免 0s 处出现一条无关的镜头切换。 + */ +export interface CameraCutSegment { + /** 切换发生的时刻(= 切点关键帧的时间) */ + time: number; + /** 切换前的激活机位(紧邻切点之前);时间轴开头无关键帧时为 fallback */ + fromCameraId: string | null; + /** 切换后的激活机位(切点关键帧所属机位) */ + toCameraId: string; + /** 切点关键帧(过渡就挂在它上面,编辑时按它定位) */ + keyframe: CameraTimelineKeyframe; + /** 解析后的过渡:切点关键帧 transition ?? 全局默认 */ + transition: CameraTransition; +} + +export function getCameraCutSegments( + 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; + } + return segments; +} + export interface CameraViewAtTime { cameraId: string | null; pose: CameraKeyframeValue; @@ -425,12 +478,28 @@ export function evaluateCameraViewAtTime( const incomingPose = evaluateCameraTrackAtTime(incomingTrack, time); if (!incomingPose) return null; - if (transition.type !== "smooth" || transition.duration <= 0) { + // 切点之前没有任何关键帧:这是时间轴的「初始机位」,不算一次切换,直接落位(与概览轨一致,避免 0s 处出现无关切换) + const hasPriorKeyframe = cameraTracks.some((track) => + track.keyframes.some((k) => k.time < segment.cutTime - 1e-6) + ); + if (!hasPriorKeyframe) { + return { cameraId: segment.cameraId, pose: incomingPose }; + } + + // 本切点的过渡:优先用切点关键帧上挂的逐个过渡,否则退回全局默认 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 >= transition.duration) { + if (elapsed >= effectiveTransition.duration) { return { cameraId: segment.cameraId, pose: incomingPose }; } @@ -446,7 +515,7 @@ export function evaluateCameraViewAtTime( const outgoingPose = evaluateCameraTrackAtTime(prevTrack, segment.cutTime); if (!outgoingPose) return { cameraId: segment.cameraId, pose: incomingPose }; - const progress = applyEasing("easeInOutCubic", elapsed / transition.duration); + const progress = applyEasing("easeInOutCubic", elapsed / effectiveTransition.duration); return { cameraId: segment.cameraId, pose: lerpCameraValue(outgoingPose, incomingPose, progress) }; } @@ -600,6 +669,30 @@ 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 CAMERA_KEYFRAME_MIN_GAP = 0.05; diff --git a/src/editor/store/directorStore.test.ts b/src/editor/store/directorStore.test.ts index d372032..04aa322 100644 --- a/src/editor/store/directorStore.test.ts +++ b/src/editor/store/directorStore.test.ts @@ -894,6 +894,7 @@ it("createDefaultDirectorProject ships a default timeline and version 2", () => cameraTracks: [], fps: 30, loop: false, + cameraTransition: { type: "smooth", duration: 0.5 }, }); }); @@ -950,6 +951,7 @@ it("replaceProject migrates a legacy v1 project into a v2 project with a timelin cameraTracks: [], fps: 30, loop: false, + cameraTransition: { type: "smooth", duration: 0.5 }, }); // 旧场景里没有 groundColor:迁移补默认地面色,否则地面会落到材质默认白 expect(project.scene.groundColor).toBe(DEFAULT_GROUND_COLOR); diff --git a/src/editor/store/directorStore.ts b/src/editor/store/directorStore.ts index 5541054..1245bea 100644 --- a/src/editor/store/directorStore.ts +++ b/src/editor/store/directorStore.ts @@ -51,6 +51,7 @@ import { updateCameraKeyframeTime as updateTimelineCameraKeyframeTime, updateCameraKeyframeValue as updateTimelineCameraKeyframeValue, updateCameraKeyframeEasing as updateTimelineCameraKeyframeEasing, + setCameraKeyframeTransition as setTimelineCameraKeyframeTransition, setCameraKeyframeTimes as setTimelineCameraKeyframeTimes, updateTimelineDuration, updateTimelinePlaybackSettings, @@ -283,6 +284,7 @@ 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; /** 一次性覆盖整条运镜的所有关键帧时间(拖动「运镜时长」把手按比例缩放用) */ setCameraKeyframeTimes: (cameraId: string, times: number[]) => void; applyCameraKeyframeToShot: (cameraId: string, value: CameraKeyframeValue) => void; @@ -3324,6 +3326,14 @@ export const useDirectorStore = create((set, get) => { timeline: updateTimelineCameraKeyframeEasing(state.project.timeline, cameraId, keyframeId, easing), }, })), + setCameraKeyframeTransition: (cameraId, keyframeId, transition) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + timeline: setTimelineCameraKeyframeTransition(state.project.timeline, cameraId, keyframeId, transition), + }, + })), setCameraKeyframeTimes: (cameraId, times) => commitMutation((state) => ({ ...state, diff --git a/src/styles/index.css b/src/styles/index.css index 7a6f502..1c5a3c4 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -253,7 +253,7 @@ textarea:focus-visible { gap: 6px; padding: 4px; border: 1px solid rgb(var(--border-rgb) / 0.42); - border-radius: 14px; + border-radius: 12px; background: rgb(var(--surface-rgb) / 0.6); } @@ -261,7 +261,7 @@ textarea:focus-visible { .mode-toggle-button, .tab-row button { border: 1px solid transparent; - border-radius: 10px; + border-radius: 12px; padding: 8px 12px; color: rgb(var(--text-muted-rgb)); background: transparent; @@ -403,6 +403,7 @@ textarea:focus-visible { min-height: 0; overflow: auto; padding-right: 0; + border-radius: 12px; background: rgb(var(--panel-rgb) / 0.98); } @@ -3610,6 +3611,7 @@ button.inspector-dropdown-option.is-selected { /* 高度自适应内容:轨道多了才长高,到上限后在面板内部滚动 */ height: auto; max-height: 420px; + border-radius: 12px; } /* 视频导出期间:drawing buffer 切到固定导出分辨率,CSS 铺满会拉伸变形 → contain 保持比例 */ .director-canvas canvas.is-video-exporting { @@ -3647,8 +3649,12 @@ button.inspector-dropdown-option.is-selected { } .timeline-time-readout { display: inline-flex; + flex: 0 0 104px; align-items: baseline; + justify-content: flex-start; gap: 2px; + width: 104px; + overflow: hidden; color: rgb(var(--text-rgb)); font-size: 12px; font-weight: 600; @@ -3819,6 +3825,122 @@ button.inspector-dropdown-option.is-selected { } /* 时间轴设置弹窗:遮罩 + 卡片。草稿值只在点「完成」时才写回工程 */ +/* 机位切换概览轨:把每个切换切点画成过渡标记(硬切=竖线、平滑=渐变区),点击可编辑类型/时长 */ +.timeline-track-transition { + align-items: stretch; +} +.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); + z-index: 70; + width: 208px; + padding: 10px; + border: 1px solid rgb(var(--border-rgb) / 0.4); + border-radius: 10px; + background: rgb(var(--surface-rgb)); + box-shadow: 0 8px 24px rgb(0 0 0 / 0.28); + display: flex; + flex-direction: column; + gap: 8px; +} +.timeline-transition-editor-row { + display: grid; + grid-template-columns: 36px 1fr; + align-items: center; + gap: 8px; +} +.timeline-transition-editor-label { + color: rgb(var(--text-muted-rgb)); + font-size: 12px; +} +.timeline-transition-duration-input { + width: 56px; +} +.timeline-transition-editor-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.timeline-transition-editor-reset { + border: 0; + background: transparent; + color: rgb(var(--text-dim-rgb)); + font-size: 11px; + cursor: pointer; + padding: 2px 0; +} +.timeline-transition-editor-reset:disabled { + opacity: 0.4; + cursor: default; +} +.timeline-transition-editor-close { + border: 0; + background: transparent; + color: rgb(var(--text-dim-rgb)); + cursor: pointer; + display: inline-flex; + padding: 2px; +} +.timeline-transition-editor-close:hover { + color: rgb(var(--text-rgb)); +} + +/* 过渡编辑器里的下拉被放在 36px 1fr 的栅格列中,需要把宽度收回到可用格子内, + 否则 inspector-dropdown 自带的右侧栏宽会撑开 1fr 轨道、让菜单溢出编辑器。 */ +.timeline-transition-dropdown.inspector-dropdown { + width: 100%; + min-width: 0; +} +.timeline-transition-dropdown .inspector-dropdown-trigger { + width: 100%; +} +.timeline-transition-dropdown .inspector-dropdown-menu { + width: 100%; +} + .timeline-settings-overlay { position: fixed; inset: 0; @@ -3930,6 +4052,12 @@ button.inspector-dropdown-option.is-selected { color: rgb(var(--text-dim-rgb)); font-size: 11px; } +.timeline-transition-editor-duration-field { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} .timeline-settings-divider { height: 1px; margin: 6px 0 2px; @@ -4474,11 +4602,12 @@ body.is-timeline-dragging * { outline: 2px solid rgb(var(--accent-rgb)); outline-offset: 1px; } -/* 每个动作片段条右上角的删除按钮:默认隐藏,悬停 / 选中时显示 */ +/* 每个动作片段条右上角的删除按钮:默认隐藏,悬停 / 选中时显示。 + right 留出比右端 is-post 修剪把手(宽 6px)更大的间距,避免和把手重叠。 */ .timeline-clip-delete { position: absolute; top: 50%; - right: 3px; + right: 8px; transform: translateY(-50%); display: none; align-items: center; @@ -4835,7 +4964,7 @@ body.is-timeline-dragging * { height: auto; max-height: var(--floating-card-max-height); border: 1px solid rgb(var(--border-rgb) / 0.42); - border-radius: 18px; + border-radius: 12px; background: rgb(var(--panel-rgb) / 0.9); box-shadow: var(--ui-shadow-panel); backdrop-filter: blur(32px); @@ -4892,7 +5021,7 @@ body.is-timeline-dragging * { margin: 0; min-width: 0; border: 1px solid rgb(var(--border-rgb) / 0.42); - border-radius: 18px; + border-radius: 12px; background: rgb(var(--panel-rgb) / 0.9); box-shadow: var(--ui-shadow-panel); backdrop-filter: blur(32px);