feat(timeline): 支持逐切点机位过渡与概览轨编辑

This commit is contained in:
2026-09-19 05:44:26 +08:00
parent 2c59709f6b
commit 8d6c30b548
10 changed files with 542 additions and 37 deletions

View File

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

View File

@@ -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 打关键帧" : "显示时间轴(录制)",

View File

@@ -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<string | null>(null);
/** 当前在「机位切换」概览轨中选中的过渡(按切点关键帧 id 定位),用于弹出编辑器 */
const [selectedCutKeyframeId, setSelectedCutKeyframeId] = useState<string | null>(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<HTMLDivElement | null>(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 (
<div className="timeline-track timeline-track-transition">
<div className="timeline-track-label" title="机位切换:每个切换点的过渡类型与时长,点击可编辑">
<Repeat aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
<span className="timeline-track-name">机位切换</span>
<span className="timeline-track-count">{cuts.length}</span>
</div>
<div className="timeline-track-lane timeline-transition-lane">
{cuts.map((cut, index) => {
const leftPercent = duration > 0 ? (cut.time / duration) * 100 : 0;
const nextTime = cuts[index + 1]?.time ?? duration;
const maxDur = Math.max(0, nextTime - cut.time);
const shownDuration =
cut.transition.type === "smooth"
? Math.min(cut.transition.duration, maxDur > 0 ? maxDur : cut.transition.duration)
: 0;
const widthPercent = duration > 0 ? (shownDuration / duration) * 100 : 0;
const isSelected = cut.keyframe.id === selectedCutKeyframeId;
const fromName =
project.cameras.find((c) => c.id === cut.fromCameraId)?.name ?? (cut.fromCameraId ?? "起始");
const toName = project.cameras.find((c) => c.id === cut.toCameraId)?.name ?? cut.toCameraId;
const transitionLabel =
cut.transition.type === "smooth" ? `平滑运镜 ${formatTime(cut.transition.duration)}` : "硬切";
return (
<div
key={cut.keyframe.id}
className={`timeline-transition-marker timeline-transition-${cut.transition.type}${
isSelected ? " is-selected" : ""
}`}
style={
cut.transition.type === "smooth"
? { left: `${leftPercent}%`, width: `${widthPercent}%` }
: { left: `${leftPercent}%` }
}
role="button"
tabIndex={0}
aria-label={`机位切换 ${formatTime(cut.time)} · ${fromName} → ${toName} · ${transitionLabel}`}
title={`${fromName} → ${toName} · ${transitionLabel}(点击编辑)`}
onClick={() => setSelectedCutKeyframeId(isSelected ? null : cut.keyframe.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedCutKeyframeId(isSelected ? null : cut.keyframe.id);
}
}}
>
{cut.transition.type === "smooth" ? (
<span className="timeline-transition-duration">{formatTime(cut.transition.duration)}</span>
) : null}
</div>
);
})}
{selectedCut ? (
<div
className="timeline-transition-editor"
style={{ left: `${selLeft}%` }}
role="dialog"
aria-label="编辑机位切换过渡"
>
<div className="timeline-transition-editor-row">
<span className="timeline-transition-editor-label">类型</span>
<InspectorDropdown
className="timeline-transition-dropdown"
ariaLabel="切换类型"
value={selectedCut.transition.type}
options={[
{ value: "cut", label: "硬切" },
{ value: "smooth", label: "平滑运镜" },
]}
onChange={(value) =>
setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, {
type: value as CameraTransitionType,
duration: selectedCut.transition.duration,
})
}
/>
</div>
<div className="timeline-transition-editor-row">
<span className="timeline-transition-editor-label">时长</span>
<div className="timeline-transition-editor-duration-field">
<input
className="timeline-settings-input timeline-transition-duration-input"
aria-label="过渡时长(秒)"
type="number"
min={MIN_CAMERA_TRANSITION_DURATION}
max={MAX_CAMERA_TRANSITION_DURATION}
step={0.1}
value={selectedCut.transition.duration}
disabled={selectedCut.transition.type !== "smooth"}
onChange={(event) =>
setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, {
type: selectedCut.transition.type,
duration: Math.min(
MAX_CAMERA_TRANSITION_DURATION,
Math.max(MIN_CAMERA_TRANSITION_DURATION, Number(event.target.value))
),
})
}
/>
<span className="timeline-settings-unit">秒</span>
</div>
</div>
<div className="timeline-transition-editor-actions">
<button
type="button"
className="timeline-transition-editor-reset"
disabled={!selectedCut.keyframe.transition}
onClick={() => setCameraKeyframeTransition(selectedCut.toCameraId, selectedCut.keyframe.id, null)}
>
重置为全局默认
</button>
<button
type="button"
className="timeline-transition-editor-close"
aria-label="关闭编辑器"
onClick={() => setSelectedCutKeyframeId(null)}
>
<X aria-hidden="true" size={12} strokeWidth={2.2} />
</button>
</div>
</div>
) : null}
</div>
</div>
);
})()}
{timeline.cameraTracks.map((track) => {
const shot = project.cameras.find((c) => c.id === track.cameraId);
const trackName = shot?.name ?? track.cameraId;

View File

@@ -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 (
<group name="gltf-character-model" position={normalization.position} scale={normalization.scale}>

View File

@@ -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 (
<group name={`ue-retopology-mannequin-${bodyType}`} scale={modelScale}>

View File

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

View File

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

View File

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

View File

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

View File

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