feat(timeline): 支持逐切点机位过渡与概览轨编辑
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 打关键帧" : "显示时间轴(录制)",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user