- 用 pnpm 替换 npm,锁定 pnpm@10.33.0,删除 package-lock.json 相关说明 - package.json 将渲染层依赖移入 devDependencies,减小 app.asar 体积 - 新增 electron:smoke 冒烟脚本与 icon 生成脚本 - win 平台设置 AppUserModelID,修复任务栏图标归组 - 清理 tsBuildInfoFile 输出路径至 node_modules/.tmp - 更新测试断言以匹配画幅遮罩、取景器缩放与模型库外部资产行为 - README 与打包指南补充 pnpm、镜像与常见问题说明
1671 lines
67 KiB
Markdown
1671 lines
67 KiB
Markdown
# 关键帧动画(物体移动 + 镜头运镜)实现计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 给 3D 导演台增加关键帧动画能力——物体(人物/道具)的位移·旋转·缩放、以及相机的运镜(position + target + fov),都能在时间轴上打关键帧、线性插值,并在视口实时预览回放。
|
||
|
||
**Architecture:** 时间轴是工程数据的一部分(写进 `DirectorProject.timeline`,走现有的 undo / localStorage 持久化 / JSON 导入导出,白嫖)。它含两类轨道:`tracks`(物体,按 `DirectorObject.id`)和 `cameraTracks`(相机,按 `DirectorCameraShot.id`)。回放是**派生态**——独立的 `playbackStore` 存播放状态,`useFrame` 每帧把插值结果直接写到 Three.js 对象(物体写 `groupRef`,相机写导演视图线框 + 机位视图渲染相机),**不写主 store**(避免撑爆 undo 栈和持久化)。所有时间轴数据操作是纯函数,集中在 `timeline.ts`,便于 TDD。
|
||
|
||
**Tech Stack:** TypeScript、React 18、Zustand 5、@react-three/fiber 8(`useFrame`)、Three.js 0.184、Vitest 4。
|
||
|
||
> ⚠️ **执行约定(用户指定):本阶段不使用 git。** 计划里每个 Task 末尾的 `git add / git commit` 步骤**一律跳过**,仅执行"写代码 → 跑测试 → 构建"的部分。阶段产出直接落在工作区即可,不做版本管理。
|
||
|
||
---
|
||
|
||
## 范围
|
||
|
||
**做:**
|
||
- 物体的 `position / rotation / scale` 关键帧,**线性插值**
|
||
- 相机运镜关键帧:`position`(机位)+ `target`(看向的点)+ `fov`(变焦),线性插值
|
||
- 运镜预览**两种形态都做**:① 导演视图里相机线框移动;② 机位视图里真正进入相机画面看运镜
|
||
- 时间轴 UI:播放/暂停、时间指针拖动、时长调整、打关键帧、选中/删除/更新关键帧(物体 + 相机)
|
||
- 视口实时预览回放(`useFrame` 驱动)
|
||
- 工程 JSON / localStorage 自动带上 timeline(复用现有机制)
|
||
|
||
**不做(明确排除):**
|
||
- ❌ 角色姿势(pose / 骨骼 controls)动画——人物只作为整体移动
|
||
- ❌ 视频导出(MediaRecorder / WebCodecs)
|
||
- ❌ 缓动曲线编辑器(只线性;数据结构里物体关键帧预留 `easing` 字段但不读取)
|
||
- ❌ 四元数球面插值(物体旋转用欧拉线性插值,MVP 够用)
|
||
- ❌ `targetMode === "object"`(锁定物体的相机)的运镜——这类相机 target 跟物体走,与运镜插值冲突,UI 层**只允许 `manual` 相机打运镜关键帧**
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### 新增文件
|
||
|
||
| 文件 | 职责 |
|
||
|------|------|
|
||
| `src/editor/schema/timeline.ts` | 时间轴数据类型 + 所有纯函数(物体/相机插值、增删改查、排序、id、默认值)。**承载全部时间轴逻辑,DRY 核心** |
|
||
| `src/editor/schema/timeline.test.ts` | 纯函数单测(物体 + 相机) |
|
||
| `src/editor/runtime/playbackStore.ts` | 播放状态 zustand store(`isPlaying / playhead / duration / advance`) |
|
||
| `src/editor/runtime/playbackStore.test.ts` | store 状态机单测 |
|
||
| `src/editor/runtime/PlaybackFrameDriver.tsx` | R3F 组件:`useFrame` 推进 playhead + 把 timeline.duration 同步到 playbackStore |
|
||
| `src/editor/runtime/ActiveCameraPlaybackSync.tsx` | R3F 组件:机位视图下,`useFrame` 用相机轨道插值覆盖渲染相机的 position/lookAt/fov |
|
||
| `src/editor/panels/TimelinePanel.tsx` | 时间轴 UI(播放控制 + 物体/相机关键帧编辑) |
|
||
| `src/editor/panels/TimelinePanel.test.tsx` | UI 组件测试 |
|
||
|
||
### 修改文件
|
||
|
||
| 文件 | 改什么 |
|
||
|------|--------|
|
||
| `src/editor/schema/directorProject.ts` | `DirectorProject` 加 `timeline` 字段;`version: 1` → `2` |
|
||
| `src/editor/store/directorStore.ts` | `migrateDirectorProject` 补 v1→v2 迁移;`createDefaultDirectorProject` 带默认 timeline;`isDirectorProjectShape` 兼容 v1/v2;`replaceProject` 走 migrate;新增 5 个物体 + 5 个相机 timeline actions |
|
||
| `src/editor/store/directorStore.test.ts` | 补 timeline actions + migration 断言 |
|
||
| `src/editor/canvas/SceneRoot.tsx` | `ObjectSceneNode` 用 `useFrame` 覆盖物体 transform;`ViewportCameraRig` 用 `useFrame` 覆盖相机线框(导演视图运镜预览①);播放时禁用物体 TransformControls |
|
||
| `src/editor/canvas/DirectorCanvas.tsx` | 挂载 `<PlaybackFrameDriver />` 和 `<ActiveCameraPlaybackSync />`(机位视图运镜预览②) |
|
||
| `src/app/layout/DirectorDeskShell.tsx` | 视口列底部挂 `<TimelinePanel />` |
|
||
|
||
---
|
||
|
||
## Task 1: 时间轴数据模型 + 纯函数(物体 + 相机)
|
||
|
||
**Files:**
|
||
- Create: `src/editor/schema/timeline.ts`
|
||
- Test: `src/editor/schema/timeline.test.ts`
|
||
|
||
- [ ] **Step 1: 写失败测试 `timeline.test.ts`**
|
||
|
||
```ts
|
||
import {
|
||
DEFAULT_TIMELINE_DURATION,
|
||
createDefaultTimeline,
|
||
evaluateTrackAtTime,
|
||
evaluateCameraTrackAtTime,
|
||
sortKeyframes,
|
||
upsertKeyframe,
|
||
removeKeyframe,
|
||
updateKeyframeTime,
|
||
updateKeyframeValue,
|
||
upsertCameraKeyframe,
|
||
removeCameraKeyframe,
|
||
updateCameraKeyframeTime,
|
||
updateCameraKeyframeValue,
|
||
} from "./timeline";
|
||
|
||
const V0 = { position: [0, 0, 0] as [number, number, number], rotation: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number] };
|
||
const V1 = { position: [10, 0, 0] as [number, number, number], rotation: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number] };
|
||
const CAM_A = { position: [0, 0, 0] as [number, number, number], target: [0, 0, 0] as [number, number, number], fov: 50 };
|
||
const CAM_B = { position: [10, 0, 0] as [number, number, number], target: [0, 0, 0] as [number, number, number], fov: 70 };
|
||
|
||
it("creates a default empty timeline with both track kinds", () => {
|
||
expect(createDefaultTimeline()).toEqual({ duration: DEFAULT_TIMELINE_DURATION, tracks: [], cameraTracks: [] });
|
||
});
|
||
|
||
// ---------- 物体插值 ----------
|
||
it("clamps before the first object keyframe to the first value", () => {
|
||
const track = { objectId: "a", keyframes: [{ id: "k1", time: 1, value: V1 }] };
|
||
expect(evaluateTrackAtTime(track, 0)).toEqual(V1);
|
||
});
|
||
|
||
it("lerps an object linearly between two keyframes", () => {
|
||
const track = { objectId: "a", keyframes: [{ id: "k1", time: 0, value: V0 }, { id: "k2", time: 2, value: V1 }] };
|
||
expect(evaluateTrackAtTime(track, 1)).toEqual({ position: [5, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] });
|
||
});
|
||
|
||
it("clamps after the last object keyframe to the last value", () => {
|
||
const track = { objectId: "a", keyframes: [{ id: "k1", time: 0, value: V0 }, { id: "k2", time: 2, value: V1 }] };
|
||
expect(evaluateTrackAtTime(track, 10)).toEqual(V1);
|
||
});
|
||
|
||
it("returns null for an object track with no keyframes", () => {
|
||
expect(evaluateTrackAtTime({ objectId: "a", keyframes: [] }, 1)).toBeNull();
|
||
});
|
||
|
||
// ---------- 相机插值 ----------
|
||
it("lerps camera position/target/fov linearly between two keyframes", () => {
|
||
const track = { cameraId: "cam_1", keyframes: [{ id: "k1", time: 0, value: CAM_A }, { id: "k2", time: 2, value: CAM_B }] };
|
||
expect(evaluateCameraTrackAtTime(track, 1)).toEqual({ position: [5, 0, 0], target: [0, 0, 0], fov: 60 });
|
||
});
|
||
|
||
it("returns null for a camera track with no keyframes", () => {
|
||
expect(evaluateCameraTrackAtTime({ cameraId: "cam_1", keyframes: [] }, 1)).toBeNull();
|
||
});
|
||
|
||
// ---------- 排序 ----------
|
||
it("sorts keyframes by time", () => {
|
||
const sorted = sortKeyframes([{ id: "k2", time: 2, value: V1 }, { id: "k1", time: 0, value: V0 }]);
|
||
expect(sorted.map((k) => k.id)).toEqual(["k1", "k2"]);
|
||
});
|
||
|
||
// ---------- 物体增删改 ----------
|
||
it("upserts an object keyframe into a new track", () => {
|
||
const next = upsertKeyframe(createDefaultTimeline(), "obj_1", 1, V0, "kf_1");
|
||
expect(next.tracks).toEqual([{ objectId: "obj_1", keyframes: [{ id: "kf_1", time: 1, value: V0 }] }]);
|
||
});
|
||
|
||
it("upserts by replacing an object keyframe at the same time (within tolerance)", () => {
|
||
let timeline = upsertKeyframe(createDefaultTimeline(), "obj_1", 1, V0, "kf_1");
|
||
timeline = upsertKeyframe(timeline, "obj_1", 1.0005, V1, "kf_2");
|
||
expect(timeline.tracks[0].keyframes).toHaveLength(1);
|
||
expect(timeline.tracks[0].keyframes[0].value).toEqual(V1);
|
||
});
|
||
|
||
it("clamps object keyframe time to [0, duration] on upsert", () => {
|
||
const timeline = upsertKeyframe(createDefaultTimeline(), "obj_1", 999, V0, "kf_1");
|
||
expect(timeline.tracks[0].keyframes[0].time).toBe(DEFAULT_TIMELINE_DURATION);
|
||
});
|
||
|
||
it("removes an object keyframe and drops the empty track", () => {
|
||
let timeline = upsertKeyframe(createDefaultTimeline(), "obj_1", 1, V0, "kf_1");
|
||
timeline = removeKeyframe(timeline, "obj_1", "kf_1");
|
||
expect(timeline.tracks).toEqual([]);
|
||
});
|
||
|
||
it("updates an object keyframe time and re-sorts", () => {
|
||
let timeline = upsertKeyframe(createDefaultTimeline(), "obj_1", 1, V0, "kf_1");
|
||
timeline = upsertKeyframe(timeline, "obj_1", 2, V1, "kf_2");
|
||
timeline = updateKeyframeTime(timeline, "obj_1", "kf_2", 0.5);
|
||
expect(timeline.tracks[0].keyframes.map((k) => k.id)).toEqual(["kf_2", "kf_1"]);
|
||
});
|
||
|
||
it("updates an object keyframe value without changing time", () => {
|
||
let timeline = upsertKeyframe(createDefaultTimeline(), "obj_1", 1, V0, "kf_1");
|
||
timeline = updateKeyframeValue(timeline, "obj_1", "kf_1", V1);
|
||
expect(timeline.tracks[0].keyframes[0].value).toEqual(V1);
|
||
expect(timeline.tracks[0].keyframes[0].time).toBe(1);
|
||
});
|
||
|
||
// ---------- 相机增删改 ----------
|
||
it("upserts a camera keyframe into a new camera track", () => {
|
||
const next = upsertCameraKeyframe(createDefaultTimeline(), "cam_1", 1, CAM_A, "ckf_1");
|
||
expect(next.cameraTracks).toEqual([{ cameraId: "cam_1", keyframes: [{ id: "ckf_1", time: 1, value: CAM_A }] }]);
|
||
});
|
||
|
||
it("removes a camera keyframe and drops the empty camera track", () => {
|
||
let timeline = upsertCameraKeyframe(createDefaultTimeline(), "cam_1", 1, CAM_A, "ckf_1");
|
||
timeline = removeCameraKeyframe(timeline, "cam_1", "ckf_1");
|
||
expect(timeline.cameraTracks).toEqual([]);
|
||
});
|
||
|
||
it("updates a camera keyframe fov via updateCameraKeyframeValue", () => {
|
||
let timeline = upsertCameraKeyframe(createDefaultTimeline(), "cam_1", 1, CAM_A, "ckf_1");
|
||
timeline = updateCameraKeyframeValue(timeline, "cam_1", "ckf_1", { ...CAM_A, fov: 80 });
|
||
expect(timeline.cameraTracks[0].keyframes[0].value.fov).toBe(80);
|
||
});
|
||
|
||
it("clamps a camera keyframe time to duration", () => {
|
||
const timeline = upsertCameraKeyframe(createDefaultTimeline(), "cam_1", 999, CAM_A, "ckf_1");
|
||
expect(timeline.cameraTracks[0].keyframes[0].time).toBe(DEFAULT_TIMELINE_DURATION);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm test -- src/editor/schema/timeline.test.ts`
|
||
Expected: FAIL,报错找不到 `./timeline` 模块。
|
||
|
||
- [ ] **Step 3: 写实现 `timeline.ts`**
|
||
|
||
```ts
|
||
export type TransformTuple = [number, number, number];
|
||
|
||
// ===== 物体关键帧 =====
|
||
export interface KeyframeTransformValue {
|
||
position: TransformTuple;
|
||
rotation: TransformTuple;
|
||
scale: TransformTuple;
|
||
}
|
||
export interface TimelineKeyframe {
|
||
id: string;
|
||
time: number; // 秒,>= 0
|
||
value: KeyframeTransformValue;
|
||
easing?: "linear"; // 预留:MVP 只用线性插值,该字段当前不读取
|
||
}
|
||
export interface TimelineTrack {
|
||
objectId: string; // 对应 DirectorObject.id
|
||
keyframes: TimelineKeyframe[]; // 保持按 time 升序
|
||
}
|
||
|
||
// ===== 相机关键帧 =====
|
||
export interface CameraKeyframeValue {
|
||
position: TransformTuple;
|
||
target: TransformTuple;
|
||
fov: number;
|
||
}
|
||
export interface CameraTimelineKeyframe {
|
||
id: string;
|
||
time: number;
|
||
value: CameraKeyframeValue;
|
||
}
|
||
export interface CameraTimelineTrack {
|
||
cameraId: string; // 对应 DirectorCameraShot.id
|
||
keyframes: CameraTimelineKeyframe[];
|
||
}
|
||
|
||
export interface Timeline {
|
||
duration: number; // 秒
|
||
tracks: TimelineTrack[];
|
||
cameraTracks: CameraTimelineTrack[];
|
||
}
|
||
|
||
export const DEFAULT_TIMELINE_DURATION = 5;
|
||
const KEYFRAME_TIME_TOLERANCE = 0.001;
|
||
|
||
export function createDefaultTimeline(): Timeline {
|
||
return { duration: DEFAULT_TIMELINE_DURATION, tracks: [], cameraTracks: [] };
|
||
}
|
||
|
||
export function createKeyframeId(): string {
|
||
return `kf_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
export function sortKeyframes<K extends { time: number }>(keyframes: K[]): K[] {
|
||
return [...keyframes].sort((a, b) => a.time - b.time);
|
||
}
|
||
|
||
function clampTime(time: number, duration: number) {
|
||
return Math.max(0, Math.min(time, duration));
|
||
}
|
||
function lerp(a: number, b: number, t: number) {
|
||
return a + (b - a) * t;
|
||
}
|
||
function lerpTuple(a: TransformTuple, b: TransformTuple, t: number): TransformTuple {
|
||
return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
|
||
}
|
||
|
||
// ===== 共享求值(物体与相机同构) =====
|
||
interface TimedValue<V> {
|
||
time: number;
|
||
value: V;
|
||
}
|
||
function evaluateKeyframes<V>(
|
||
keyframes: TimedValue<V>[],
|
||
time: number,
|
||
lerpFn: (a: V, b: V, t: number) => V
|
||
): V | null {
|
||
if (keyframes.length === 0) return null;
|
||
if (time <= keyframes[0].time) return keyframes[0].value;
|
||
const last = keyframes[keyframes.length - 1];
|
||
if (time >= last.time) return last.value;
|
||
for (let i = 0; i < keyframes.length - 1; i += 1) {
|
||
const a = keyframes[i];
|
||
const b = keyframes[i + 1];
|
||
if (time >= a.time && time <= b.time) {
|
||
const span = b.time - a.time;
|
||
const t = span === 0 ? 0 : (time - a.time) / span;
|
||
return lerpFn(a.value, b.value, t);
|
||
}
|
||
}
|
||
return last.value;
|
||
}
|
||
|
||
// ===== 物体插值 =====
|
||
function lerpTransformValue(a: KeyframeTransformValue, b: KeyframeTransformValue, t: number): KeyframeTransformValue {
|
||
return {
|
||
position: lerpTuple(a.position, b.position, t),
|
||
rotation: lerpTuple(a.rotation, b.rotation, t),
|
||
scale: lerpTuple(a.scale, b.scale, t),
|
||
};
|
||
}
|
||
export function evaluateTrackAtTime(track: TimelineTrack, time: number): KeyframeTransformValue | null {
|
||
return evaluateKeyframes(track.keyframes, time, lerpTransformValue);
|
||
}
|
||
|
||
// ===== 相机插值 =====
|
||
function lerpCameraValue(a: CameraKeyframeValue, b: CameraKeyframeValue, t: number): CameraKeyframeValue {
|
||
return {
|
||
position: lerpTuple(a.position, b.position, t),
|
||
target: lerpTuple(a.target, b.target, t),
|
||
fov: lerp(a.fov, b.fov, t),
|
||
};
|
||
}
|
||
export function evaluateCameraTrackAtTime(track: CameraTimelineTrack, time: number): CameraKeyframeValue | null {
|
||
return evaluateKeyframes(track.keyframes, time, lerpCameraValue);
|
||
}
|
||
|
||
// ===== 物体增删改 =====
|
||
export function upsertKeyframe(
|
||
timeline: Timeline,
|
||
objectId: string,
|
||
time: number,
|
||
value: KeyframeTransformValue,
|
||
keyframeId: string = createKeyframeId()
|
||
): Timeline {
|
||
const safeTime = clampTime(time, timeline.duration);
|
||
let touchedExisting = false;
|
||
const tracks = timeline.tracks.map((track) => {
|
||
if (track.objectId !== objectId) return track;
|
||
const idx = track.keyframes.findIndex((k) => Math.abs(k.time - safeTime) < KEYFRAME_TIME_TOLERANCE);
|
||
if (idx >= 0) {
|
||
touchedExisting = true;
|
||
return { ...track, keyframes: track.keyframes.map((k, i) => (i === idx ? { ...k, value } : k)) };
|
||
}
|
||
return { ...track, keyframes: sortKeyframes([...track.keyframes, { id: keyframeId, time: safeTime, value }]) };
|
||
});
|
||
if (touchedExisting || tracks.some((t) => t.objectId === objectId)) return { ...timeline, tracks };
|
||
return { ...timeline, tracks: [...timeline.tracks, { objectId, keyframes: [{ id: keyframeId, time: safeTime, value }] }] };
|
||
}
|
||
|
||
export function removeKeyframe(timeline: Timeline, objectId: string, keyframeId: string): Timeline {
|
||
return {
|
||
...timeline,
|
||
tracks: timeline.tracks
|
||
.map((track) =>
|
||
track.objectId === objectId
|
||
? { ...track, keyframes: track.keyframes.filter((k) => k.id !== keyframeId) }
|
||
: track
|
||
)
|
||
.filter((track) => track.keyframes.length > 0),
|
||
};
|
||
}
|
||
|
||
export function updateKeyframeTime(timeline: Timeline, objectId: string, keyframeId: string, time: number): Timeline {
|
||
const safeTime = clampTime(time, timeline.duration);
|
||
return {
|
||
...timeline,
|
||
tracks: timeline.tracks.map((track) => {
|
||
if (track.objectId !== objectId) return track;
|
||
return { ...track, keyframes: sortKeyframes(track.keyframes.map((k) => (k.id === keyframeId ? { ...k, time: safeTime } : k))) };
|
||
}),
|
||
};
|
||
}
|
||
|
||
export function updateKeyframeValue(timeline: Timeline, objectId: string, keyframeId: string, value: KeyframeTransformValue): Timeline {
|
||
return {
|
||
...timeline,
|
||
tracks: timeline.tracks.map((track) =>
|
||
track.objectId === objectId
|
||
? { ...track, keyframes: track.keyframes.map((k) => (k.id === keyframeId ? { ...k, value } : k)) }
|
||
: track
|
||
),
|
||
};
|
||
}
|
||
|
||
// ===== 相机增删改(与物体同构,操作 cameraTracks) =====
|
||
export function upsertCameraKeyframe(
|
||
timeline: Timeline,
|
||
cameraId: string,
|
||
time: number,
|
||
value: CameraKeyframeValue,
|
||
keyframeId: string = createKeyframeId()
|
||
): Timeline {
|
||
const safeTime = clampTime(time, timeline.duration);
|
||
let touchedExisting = false;
|
||
const cameraTracks = timeline.cameraTracks.map((track) => {
|
||
if (track.cameraId !== cameraId) return track;
|
||
const idx = track.keyframes.findIndex((k) => Math.abs(k.time - safeTime) < KEYFRAME_TIME_TOLERANCE);
|
||
if (idx >= 0) {
|
||
touchedExisting = true;
|
||
return { ...track, keyframes: track.keyframes.map((k, i) => (i === idx ? { ...k, value } : k)) };
|
||
}
|
||
return { ...track, keyframes: sortKeyframes([...track.keyframes, { id: keyframeId, time: safeTime, value }]) };
|
||
});
|
||
if (touchedExisting || cameraTracks.some((t) => t.cameraId === cameraId)) return { ...timeline, cameraTracks };
|
||
return { ...timeline, cameraTracks: [...timeline.cameraTracks, { cameraId, keyframes: [{ id: keyframeId, time: safeTime, value }] }] };
|
||
}
|
||
|
||
export function removeCameraKeyframe(timeline: Timeline, cameraId: string, keyframeId: string): Timeline {
|
||
return {
|
||
...timeline,
|
||
cameraTracks: timeline.cameraTracks
|
||
.map((track) =>
|
||
track.cameraId === cameraId
|
||
? { ...track, keyframes: track.keyframes.filter((k) => k.id !== keyframeId) }
|
||
: track
|
||
)
|
||
.filter((track) => track.keyframes.length > 0),
|
||
};
|
||
}
|
||
|
||
export function updateCameraKeyframeTime(timeline: Timeline, cameraId: string, keyframeId: string, time: number): Timeline {
|
||
const safeTime = clampTime(time, timeline.duration);
|
||
return {
|
||
...timeline,
|
||
cameraTracks: timeline.cameraTracks.map((track) => {
|
||
if (track.cameraId !== cameraId) return track;
|
||
return { ...track, keyframes: sortKeyframes(track.keyframes.map((k) => (k.id === keyframeId ? { ...k, time: safeTime } : k))) };
|
||
}),
|
||
};
|
||
}
|
||
|
||
export function updateCameraKeyframeValue(timeline: Timeline, cameraId: string, keyframeId: string, value: CameraKeyframeValue): Timeline {
|
||
return {
|
||
...timeline,
|
||
cameraTracks: timeline.cameraTracks.map((track) =>
|
||
track.cameraId === cameraId
|
||
? { ...track, keyframes: track.keyframes.map((k) => (k.id === keyframeId ? { ...k, value } : k)) }
|
||
: track
|
||
),
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `pnpm test -- src/editor/schema/timeline.test.ts`
|
||
Expected: PASS(全部用例)。
|
||
|
||
- [ ] **Step 5: 提交(按约定跳过 git)**
|
||
|
||
```bash
|
||
git add src/editor/schema/timeline.ts src/editor/schema/timeline.test.ts
|
||
git commit -m "feat(timeline): add timeline schema with object and camera tracks"
|
||
```
|
||
> ⚠️ 跳过本步骤(本阶段不用 git)。
|
||
|
||
---
|
||
|
||
## Task 2: DirectorProject schema 升级 + migration
|
||
|
||
**Files:**
|
||
- Modify: `src/editor/schema/directorProject.ts`
|
||
- Modify: `src/editor/store/directorStore.ts`(migration + 默认值 + shape 校验 + replaceProject 走 migrate)
|
||
- Test: `src/editor/store/directorStore.test.ts`
|
||
|
||
- [ ] **Step 1: 改 `directorProject.ts`——加 timeline 字段、version 升 2**
|
||
|
||
在文件顶部 import 区加:
|
||
```ts
|
||
import type { Timeline } from "./timeline";
|
||
```
|
||
|
||
把 `DirectorProject` 改成:
|
||
```ts
|
||
export interface DirectorProject {
|
||
version: 2;
|
||
scene: SceneSettings;
|
||
assets: DirectorAssetRef[];
|
||
objects: DirectorObject[];
|
||
cameras: DirectorCameraShot[];
|
||
activeCameraId: string | null;
|
||
panoramaAssetId: string | null;
|
||
timeline: Timeline;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 改 `directorStore.ts`——import + 默认 timeline + migration + shape 校验**
|
||
|
||
顶部 import 替换/扩充为:
|
||
```ts
|
||
import {
|
||
createDefaultTimeline,
|
||
upsertKeyframe,
|
||
removeKeyframe as removeTimelineKeyframe,
|
||
updateKeyframeTime as updateTimelineKeyframeTime,
|
||
updateKeyframeValue as updateTimelineKeyframeValue,
|
||
upsertCameraKeyframe,
|
||
removeCameraKeyframe as removeTimelineCameraKeyframe,
|
||
updateCameraKeyframeTime as updateTimelineCameraKeyframeTime,
|
||
updateCameraKeyframeValue as updateTimelineCameraKeyframeValue,
|
||
createKeyframeId,
|
||
type KeyframeTransformValue,
|
||
type CameraKeyframeValue,
|
||
type Timeline,
|
||
} from "../schema/timeline";
|
||
```
|
||
|
||
在 `migrateDirectorProject` 之前加辅助:
|
||
```ts
|
||
function withTimeline(project: DirectorProject): DirectorProject {
|
||
const timeline = project.timeline ?? createDefaultTimeline();
|
||
return {
|
||
...project,
|
||
version: 2,
|
||
timeline: {
|
||
duration: timeline.duration,
|
||
tracks: timeline.tracks ?? [],
|
||
cameraTracks: timeline.cameraTracks ?? [],
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
把 `migrateDirectorProject` 改为先补 timeline 再做 ue4 迁移:
|
||
```ts
|
||
function migrateDirectorProject(project: DirectorProject): DirectorProject {
|
||
const withTimelineProject = withTimeline(project);
|
||
return {
|
||
...withTimelineProject,
|
||
objects: withTimelineProject.objects.map((object) => {
|
||
if (object.kind !== "character") return object;
|
||
|
||
const rig = object.characterRig;
|
||
if (rig?.rigType === "ue4-mannequin") return object;
|
||
|
||
return {
|
||
...object,
|
||
characterRig: {
|
||
rigType: "ue4-mannequin",
|
||
posePresetId: rig?.posePresetId ?? "stand",
|
||
controls: rig?.controls ?? {},
|
||
},
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
```
|
||
|
||
把 `isDirectorProjectShape` 改成兼容 v1/v2:
|
||
```ts
|
||
function isDirectorProjectShape(value: unknown): value is DirectorProject {
|
||
if (!value || typeof value !== "object") return false;
|
||
|
||
const project = value as Partial<DirectorProject>;
|
||
return (
|
||
(project.version === 1 || project.version === 2) &&
|
||
Array.isArray(project.assets) &&
|
||
Array.isArray(project.objects) &&
|
||
Array.isArray(project.cameras) &&
|
||
Boolean(project.scene) &&
|
||
typeof project.scene?.backgroundColor === "string"
|
||
);
|
||
}
|
||
```
|
||
|
||
把 `createDefaultDirectorProject` 的返回对象加上 `version: 2` 和 `timeline: createDefaultTimeline()`:
|
||
```ts
|
||
return {
|
||
version: 2,
|
||
scene: DEFAULT_SCENE,
|
||
assets: includePersistedLocalAssets ? readPersistedLocalModelAssets() : [],
|
||
objects: [role, cameraObject],
|
||
cameras: [camera],
|
||
activeCameraId: camera.id,
|
||
panoramaAssetId: null,
|
||
timeline: createDefaultTimeline(),
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 3: 让 `replaceProject` 走 migrate(v1 旧工程导入也能补齐 timeline)**
|
||
|
||
把 `replaceProject` 改为:
|
||
```ts
|
||
replaceProject: (project) =>
|
||
commitMutation((state) => {
|
||
const incoming = cloneJsonValue(project);
|
||
const migrated = migrateDirectorProject(
|
||
isDirectorProjectShape(incoming) ? incoming : createDefaultDirectorProject()
|
||
);
|
||
return {
|
||
...state,
|
||
project: migrated,
|
||
selectedObjectId: null,
|
||
selectedObjectIds: [],
|
||
selectedCrowdId: null,
|
||
directorInspectorMode: "auto",
|
||
};
|
||
}),
|
||
```
|
||
|
||
- [ ] **Step 4: 写 migration 测试**
|
||
|
||
在 `src/editor/store/directorStore.test.ts` 追加(`useDirectorStore`、`createDefaultDirectorProject` 需要 import):
|
||
```ts
|
||
it("createDefaultDirectorProject ships a default timeline and version 2", () => {
|
||
const project = createDefaultDirectorProject();
|
||
expect(project.version).toBe(2);
|
||
expect(project.timeline).toEqual({ duration: 5, tracks: [], cameraTracks: [] });
|
||
});
|
||
|
||
it("replaceProject migrates a legacy v1 project into a v2 project with a timeline", () => {
|
||
const legacyV1 = {
|
||
version: 1,
|
||
scene: { scale: 1, position: [0, 0, 0], rotation: [0, 0, 0], backgroundColor: "#000000", panoramaYaw: 0, panoramaRadius: 60, showLabels: true, snapToGrid: false, showGround: true, groundOpacity: 0.4, groundHeight: 0 },
|
||
assets: [],
|
||
objects: [],
|
||
cameras: [],
|
||
activeCameraId: null,
|
||
panoramaAssetId: null,
|
||
};
|
||
|
||
useDirectorStore.getState().replaceProject(legacyV1 as never);
|
||
|
||
const project = useDirectorStore.getState().project;
|
||
expect(project.version).toBe(2);
|
||
expect(project.timeline).toEqual({ duration: 5, tracks: [], cameraTracks: [] });
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: 跑测试确认通过**
|
||
|
||
Run: `pnpm test -- src/editor/store/directorStore.test.ts`
|
||
Expected: PASS(含新增 migration 用例 + 既有用例不被破坏)。
|
||
|
||
- [ ] **Step 6: 提交(按约定跳过 git)**
|
||
|
||
```bash
|
||
git add src/editor/schema/directorProject.ts src/editor/store/directorStore.ts src/editor/store/directorStore.test.ts
|
||
git commit -m "feat(timeline): add timeline field to DirectorProject with v1->v2 migration"
|
||
```
|
||
> ⚠️ 跳过本步骤(本阶段不用 git)。
|
||
|
||
---
|
||
|
||
## Task 3: directorStore 时间轴 actions(物体 + 相机)
|
||
|
||
**Files:**
|
||
- Modify: `src/editor/store/directorStore.ts`
|
||
- Test: `src/editor/store/directorStore.test.ts`
|
||
|
||
- [ ] **Step 1: 在 `DirectorActions` 接口加 action 签名**
|
||
|
||
在 `DirectorActions` 接口里(`updateCamera` 之后、`beginUndoBatch` 之前)加:
|
||
```ts
|
||
// 物体关键帧
|
||
addKeyframe: (objectId: string, time: number, value: KeyframeTransformValue) => string;
|
||
removeKeyframe: (objectId: string, keyframeId: string) => void;
|
||
updateKeyframeTime: (objectId: string, keyframeId: string, time: number) => void;
|
||
updateKeyframeValue: (objectId: string, keyframeId: string, value: KeyframeTransformValue) => void;
|
||
applyKeyframeToObjectTransform: (objectId: string, value: KeyframeTransformValue) => void;
|
||
// 相机运镜关键帧
|
||
addCameraKeyframe: (cameraId: string, time: number, value: CameraKeyframeValue) => string;
|
||
removeCameraKeyframe: (cameraId: string, keyframeId: string) => void;
|
||
updateCameraKeyframeTime: (cameraId: string, keyframeId: string, time: number) => void;
|
||
updateCameraKeyframeValue: (cameraId: string, keyframeId: string, value: CameraKeyframeValue) => void;
|
||
applyCameraKeyframeToShot: (cameraId: string, value: CameraKeyframeValue) => void;
|
||
// 时长
|
||
setTimelineDuration: (seconds: number) => void;
|
||
```
|
||
> `KeyframeTransformValue` / `CameraKeyframeValue` 已在 Task 2 的顶部 import 中引入。
|
||
|
||
- [ ] **Step 2: 写失败测试**
|
||
|
||
在 `directorStore.test.ts` 追加:
|
||
```ts
|
||
it("addKeyframe inserts a keyframe on the object's track and returns its id", () => {
|
||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
|
||
const objectId = useDirectorStore.getState().project.objects[0].id;
|
||
|
||
const kfId = useDirectorStore.getState().addKeyframe(objectId, 1, {
|
||
position: [1, 2, 3], rotation: [0, 0, 0], scale: [1, 1, 1],
|
||
});
|
||
|
||
expect(typeof kfId).toBe("string");
|
||
const track = useDirectorStore.getState().project.timeline.tracks.find((t) => t.objectId === objectId);
|
||
expect(track?.keyframes).toHaveLength(1);
|
||
expect(track?.keyframes[0].id).toBe(kfId);
|
||
});
|
||
|
||
it("applyKeyframeToObjectTransform writes the keyframe value onto the object transform", () => {
|
||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
|
||
const objectId = useDirectorStore.getState().project.objects[0].id;
|
||
|
||
useDirectorStore.getState().applyKeyframeToObjectTransform(objectId, {
|
||
position: [5, 6, 7], rotation: [0.1, 0.2, 0.3], scale: [2, 2, 2],
|
||
});
|
||
|
||
const obj = useDirectorStore.getState().project.objects.find((o) => o.id === objectId);
|
||
expect(obj?.transform.position).toEqual([5, 6, 7]);
|
||
expect(obj?.transform.scale).toEqual([2, 2, 2]);
|
||
});
|
||
|
||
it("addCameraKeyframe inserts a keyframe on the camera's track", () => {
|
||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
|
||
const cameraId = useDirectorStore.getState().project.cameras[0].id;
|
||
|
||
const kfId = useDirectorStore.getState().addCameraKeyframe(cameraId, 1, {
|
||
position: [3, 3, 3], target: [0, 1, 0], fov: 40,
|
||
});
|
||
|
||
expect(typeof kfId).toBe("string");
|
||
const track = useDirectorStore.getState().project.timeline.cameraTracks.find((t) => t.cameraId === cameraId);
|
||
expect(track?.keyframes).toHaveLength(1);
|
||
expect(track?.keyframes[0].value.fov).toBe(40);
|
||
});
|
||
|
||
it("applyCameraKeyframeToShot writes position/target/fov onto the camera shot", () => {
|
||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
|
||
const cameraId = useDirectorStore.getState().project.cameras[0].id;
|
||
|
||
useDirectorStore.getState().applyCameraKeyframeToShot(cameraId, {
|
||
position: [8, 2, 9], target: [1, 1, 1], fov: 33,
|
||
});
|
||
|
||
const shot = useDirectorStore.getState().project.cameras.find((c) => c.id === cameraId);
|
||
expect(shot?.transform.position).toEqual([8, 2, 9]);
|
||
expect(shot?.target).toEqual([1, 1, 1]);
|
||
expect(shot?.fov).toBe(33);
|
||
});
|
||
|
||
it("removeCameraKeyframe drops the empty camera track", () => {
|
||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
|
||
const cameraId = useDirectorStore.getState().project.cameras[0].id;
|
||
const kfId = useDirectorStore.getState().addCameraKeyframe(cameraId, 1, {
|
||
position: [0, 0, 0], target: [0, 0, 0], fov: 50,
|
||
});
|
||
|
||
useDirectorStore.getState().removeCameraKeyframe(cameraId, kfId);
|
||
|
||
expect(useDirectorStore.getState().project.timeline.cameraTracks).toEqual([]);
|
||
});
|
||
|
||
it("setTimelineDuration updates the timeline duration", () => {
|
||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
|
||
useDirectorStore.getState().setTimelineDuration(12);
|
||
expect(useDirectorStore.getState().project.timeline.duration).toBe(12);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 跑测试确认失败**
|
||
|
||
Run: `pnpm test -- src/editor/store/directorStore.test.ts`
|
||
Expected: FAIL(actions 未定义)。
|
||
|
||
- [ ] **Step 4: 实现 actions**
|
||
|
||
在 store 返回对象里(建议放在 `updateCamera` 之后)加:
|
||
```ts
|
||
addKeyframe: (objectId, time, value) => {
|
||
const keyframeId = createKeyframeId();
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: upsertKeyframe(state.project.timeline, objectId, time, value, keyframeId),
|
||
},
|
||
}));
|
||
return keyframeId;
|
||
},
|
||
removeKeyframe: (objectId, keyframeId) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: removeTimelineKeyframe(state.project.timeline, objectId, keyframeId),
|
||
},
|
||
})),
|
||
updateKeyframeTime: (objectId, keyframeId, time) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: updateTimelineKeyframeTime(state.project.timeline, objectId, keyframeId, time),
|
||
},
|
||
})),
|
||
updateKeyframeValue: (objectId, keyframeId, value) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: updateTimelineKeyframeValue(state.project.timeline, objectId, keyframeId, value),
|
||
},
|
||
})),
|
||
applyKeyframeToObjectTransform: (objectId, value) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
objects: updateObjectById(state.project.objects, objectId, (item) => ({
|
||
...item,
|
||
transform: { position: value.position, rotation: value.rotation, scale: value.scale },
|
||
})),
|
||
},
|
||
})),
|
||
addCameraKeyframe: (cameraId, time, value) => {
|
||
const keyframeId = createKeyframeId();
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: upsertCameraKeyframe(state.project.timeline, cameraId, time, value, keyframeId),
|
||
},
|
||
}));
|
||
return keyframeId;
|
||
},
|
||
removeCameraKeyframe: (cameraId, keyframeId) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: removeTimelineCameraKeyframe(state.project.timeline, cameraId, keyframeId),
|
||
},
|
||
})),
|
||
updateCameraKeyframeTime: (cameraId, keyframeId, time) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: updateTimelineCameraKeyframeTime(state.project.timeline, cameraId, keyframeId, time),
|
||
},
|
||
})),
|
||
updateCameraKeyframeValue: (cameraId, keyframeId, value) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: updateTimelineCameraKeyframeValue(state.project.timeline, cameraId, keyframeId, value),
|
||
},
|
||
})),
|
||
applyCameraKeyframeToShot: (cameraId, value) => {
|
||
// 只改 position/target/fov,rotation/scale 保留 shot 原值(相机朝向由 target 决定)
|
||
const camera = get().project.cameras.find((item) => item.id === cameraId);
|
||
if (!camera) return;
|
||
get().updateCamera(cameraId, {
|
||
fov: value.fov,
|
||
target: value.target,
|
||
transform: { ...camera.transform, position: value.position },
|
||
});
|
||
},
|
||
setTimelineDuration: (seconds) =>
|
||
commitMutation((state) => ({
|
||
...state,
|
||
project: {
|
||
...state.project,
|
||
timeline: { ...state.project.timeline, duration: Math.max(0.1, seconds) },
|
||
},
|
||
})),
|
||
```
|
||
|
||
- [ ] **Step 5: 跑测试确认通过**
|
||
|
||
Run: `pnpm test -- src/editor/store/directorStore.test.ts`
|
||
Expected: PASS。
|
||
|
||
- [ ] **Step 6: 提交(按约定跳过 git)**
|
||
|
||
```bash
|
||
git add src/editor/store/directorStore.ts src/editor/store/directorStore.test.ts
|
||
git commit -m "feat(timeline): add object and camera keyframe actions to director store"
|
||
```
|
||
> ⚠️ 跳过本步骤(本阶段不用 git)。
|
||
|
||
---
|
||
|
||
## Task 4: playbackStore(播放状态)
|
||
|
||
**Files:**
|
||
- Create: `src/editor/runtime/playbackStore.ts`
|
||
- Test: `src/editor/runtime/playbackStore.test.ts`
|
||
|
||
- [ ] **Step 1: 写失败测试**
|
||
|
||
```ts
|
||
import { usePlaybackStore } from "./playbackStore";
|
||
|
||
beforeEach(() => {
|
||
usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5 });
|
||
});
|
||
|
||
it("togglePlaying flips isPlaying", () => {
|
||
usePlaybackStore.getState().togglePlaying();
|
||
expect(usePlaybackStore.getState().isPlaying).toBe(true);
|
||
});
|
||
|
||
it("setPlayhead clamps to [0, duration]", () => {
|
||
usePlaybackStore.getState().setPlayhead(3);
|
||
expect(usePlaybackStore.getState().playhead).toBe(3);
|
||
usePlaybackStore.getState().setPlayhead(-1);
|
||
expect(usePlaybackStore.getState().playhead).toBe(0);
|
||
usePlaybackStore.getState().setPlayhead(99);
|
||
expect(usePlaybackStore.getState().playhead).toBe(5);
|
||
});
|
||
|
||
it("advance does nothing when not playing", () => {
|
||
usePlaybackStore.getState().advance(0.5);
|
||
expect(usePlaybackStore.getState().playhead).toBe(0);
|
||
});
|
||
|
||
it("advance moves playhead forward and loops back to 0 at the end", () => {
|
||
usePlaybackStore.getState().setPlaying(true);
|
||
usePlaybackStore.getState().setPlayhead(4.8);
|
||
usePlaybackStore.getState().advance(0.5);
|
||
expect(usePlaybackStore.getState().playhead).toBe(0);
|
||
});
|
||
|
||
it("setDuration keeps a positive minimum", () => {
|
||
usePlaybackStore.getState().setDuration(0);
|
||
expect(usePlaybackStore.getState().duration).toBeGreaterThanOrEqual(0.1);
|
||
});
|
||
```
|
||
|
||
> 参考仓库现有 `.test.ts` 风格:不显式 import vitest 的 globals。若 `beforeEach` 报未定义,再加 `import { beforeEach, it, expect } from "vitest";`。
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm test -- src/editor/runtime/playbackStore.test.ts`
|
||
Expected: FAIL(找不到模块)。
|
||
|
||
- [ ] **Step 3: 写实现**
|
||
|
||
```ts
|
||
import { create } from "zustand";
|
||
import { DEFAULT_TIMELINE_DURATION } from "../schema/timeline";
|
||
|
||
export interface PlaybackState {
|
||
isPlaying: boolean;
|
||
playhead: number; // 秒
|
||
duration: number; // 秒,与 timeline.duration 保持同步
|
||
setPlaying: (playing: boolean) => void;
|
||
togglePlaying: () => void;
|
||
setPlayhead: (time: number) => void;
|
||
setDuration: (duration: number) => void;
|
||
/** 供 useFrame 调用:推进 playhead,到末尾循环回 0;非播放态为空操作 */
|
||
advance: (deltaSeconds: number) => void;
|
||
}
|
||
|
||
function clamp(value: number, min: number, max: number) {
|
||
return Math.max(min, Math.min(max, value));
|
||
}
|
||
|
||
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||
isPlaying: false,
|
||
playhead: 0,
|
||
duration: DEFAULT_TIMELINE_DURATION,
|
||
setPlaying: (isPlaying) => set({ isPlaying }),
|
||
togglePlaying: () => set((state) => ({ isPlaying: !state.isPlaying })),
|
||
setPlayhead: (playhead) =>
|
||
set((state) => ({ playhead: clamp(playhead, 0, state.duration) })),
|
||
setDuration: (duration) => set({ duration: Math.max(0.1, duration) }),
|
||
advance: (deltaSeconds) => {
|
||
if (!get().isPlaying) return;
|
||
set((state) => {
|
||
const next = state.playhead + deltaSeconds;
|
||
return { playhead: next >= state.duration ? 0 : next };
|
||
});
|
||
},
|
||
}));
|
||
```
|
||
|
||
- [ ] **Step 4: 跑测试确认通过**
|
||
|
||
Run: `pnpm test -- src/editor/runtime/playbackStore.test.ts`
|
||
Expected: PASS。
|
||
|
||
- [ ] **Step 5: 提交(按约定跳过 git)**
|
||
|
||
```bash
|
||
git add src/editor/runtime/playbackStore.ts src/editor/runtime/playbackStore.test.ts
|
||
git commit -m "feat(timeline): add playback store for playhead and play state"
|
||
```
|
||
> ⚠️ 跳过本步骤(本阶段不用 git)。
|
||
|
||
---
|
||
|
||
## Task 5: 回放驱动 + 物体接入
|
||
|
||
> 核心逻辑(`evaluateTrackAtTime`)已在 Task 1 被单测覆盖。`useFrame` 集成在 R3F canvas 内运行、难以单测,本 task 不写组件单测,改在 Task 8 手测验证。
|
||
|
||
**Files:**
|
||
- Create: `src/editor/runtime/PlaybackFrameDriver.tsx`
|
||
- Modify: `src/editor/canvas/SceneRoot.tsx`(仅物体部分;相机线框在 Task 6)
|
||
- Modify: `src/editor/canvas/DirectorCanvas.tsx`(挂载驱动器)
|
||
|
||
- [ ] **Step 1: 写 `PlaybackFrameDriver.tsx`**
|
||
|
||
```tsx
|
||
import { useFrame } from "@react-three/fiber";
|
||
import { useEffect } from "react";
|
||
import { useDirectorStore } from "../store/directorStore";
|
||
import { DEFAULT_TIMELINE_DURATION } from "../schema/timeline";
|
||
import { usePlaybackStore } from "./playbackStore";
|
||
|
||
/**
|
||
* 挂在 R3F <Canvas> 内部:
|
||
* 1. 每帧推进 playhead(delta 是秒,钳制最大步长防止切 tab 后大跳)
|
||
* 2. 把 directorStore 的 timeline.duration 同步到 playbackStore
|
||
*/
|
||
export function PlaybackFrameDriver() {
|
||
const advance = usePlaybackStore((state) => state.advance);
|
||
const timelineDuration = useDirectorStore(
|
||
(state) => state.project.timeline?.duration ?? DEFAULT_TIMELINE_DURATION
|
||
);
|
||
const setDuration = usePlaybackStore((state) => state.setDuration);
|
||
|
||
useEffect(() => {
|
||
setDuration(timelineDuration);
|
||
}, [timelineDuration, setDuration]);
|
||
|
||
useFrame((_, delta) => {
|
||
advance(Math.min(delta, 0.1));
|
||
});
|
||
|
||
return null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 改 `SceneRoot.tsx`——`ObjectSceneNode` 接入回放覆盖**
|
||
|
||
在 `SceneRoot.tsx` 顶部 import 区加:
|
||
```tsx
|
||
import { useFrame } from "@react-three/fiber";
|
||
import { evaluateTrackAtTime } from "../schema/timeline";
|
||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||
```
|
||
|
||
在 `ObjectSceneNode` 组件内(`const updateObjectTransform = ...` 这一行之后)加:
|
||
```tsx
|
||
const timeline = useDirectorStore((state) => state.project.timeline);
|
||
const playhead = usePlaybackStore((state) => state.playhead);
|
||
const isPlaying = usePlaybackStore((state) => state.isPlaying);
|
||
|
||
useFrame(() => {
|
||
const group = groupRef.current;
|
||
if (!group) return;
|
||
const track = timeline.tracks.find((t) => t.objectId === item.id);
|
||
if (!track || track.keyframes.length === 0) return;
|
||
const value = evaluateTrackAtTime(track, playhead);
|
||
if (!value) return;
|
||
group.position.set(value.position[0], value.position[1], value.position[2]);
|
||
group.rotation.set(value.rotation[0], value.rotation[1], value.rotation[2]);
|
||
group.scale.set(value.scale[0], value.scale[1], value.scale[2]);
|
||
});
|
||
```
|
||
|
||
> 说明:`timeline` / `playhead` 来自 store 订阅,但 playhead 变化**不会**触发 React 重渲染(SceneRoot 订阅的是 directorStore;playbackStore 的变化只被 `useFrame` 读取)。播放时无 React 重渲染开销,每帧直接改 `group`。
|
||
|
||
- [ ] **Step 3: 播放时禁用物体 TransformControls**
|
||
|
||
把 `ObjectSceneNode` 末尾的:
|
||
```tsx
|
||
if (!selected || !transformable) return node;
|
||
|
||
return (
|
||
<>
|
||
{node}
|
||
<ViewportTransformControls ... />
|
||
</>
|
||
);
|
||
```
|
||
改为:
|
||
```tsx
|
||
const showGizmo = selected && transformable && !isPlaying;
|
||
|
||
if (!showGizmo) return node;
|
||
|
||
return (
|
||
<>
|
||
{node}
|
||
<ViewportTransformControls
|
||
mode={transformMode}
|
||
object={groupRef}
|
||
onObjectChange={commitTransformFromViewport}
|
||
translationSnap={transformMode === "translate" ? translationSnap : null}
|
||
/>
|
||
</>
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 4: 改 `DirectorCanvas.tsx`——挂载驱动器**
|
||
|
||
在 `DirectorCanvas.tsx` 顶部 import:
|
||
```tsx
|
||
import { PlaybackFrameDriver } from "../runtime/PlaybackFrameDriver";
|
||
```
|
||
|
||
在 `<Canvas>` 内部、`<CanvasCaptureBridge ... />` 之后加:
|
||
```tsx
|
||
<PlaybackFrameDriver />
|
||
```
|
||
|
||
- [ ] **Step 5: 构建 + 类型检查**
|
||
|
||
Run: `pnpm run build`
|
||
Expected: 构建通过(tsc -b 无类型错误,vite build 成功)。
|
||
|
||
- [ ] **Step 6: 提交(按约定跳过 git)**
|
||
|
||
```bash
|
||
git add src/editor/runtime/PlaybackFrameDriver.tsx src/editor/canvas/SceneRoot.tsx src/editor/canvas/DirectorCanvas.tsx
|
||
git commit -m "feat(timeline): drive playback via useFrame and override object transforms"
|
||
```
|
||
> ⚠️ 跳过本步骤(本阶段不用 git)。
|
||
|
||
---
|
||
|
||
## Task 6: 相机运镜回放接入(导演视图线框 + 机位视图画面)
|
||
|
||
> 两条预览路径都用 `useFrame` 直接写 Three.js 对象,不写 store。无组件单测,靠 Task 8 手测验证。
|
||
|
||
**Files:**
|
||
- Create: `src/editor/runtime/ActiveCameraPlaybackSync.tsx`
|
||
- Modify: `src/editor/canvas/SceneRoot.tsx`(`ViewportCameraRig` 加线框覆盖)
|
||
- Modify: `src/editor/canvas/DirectorCanvas.tsx`(挂载 `ActiveCameraPlaybackSync`)
|
||
|
||
- [ ] **Step 1: 写 `ActiveCameraPlaybackSync.tsx`(机位视图运镜预览②)**
|
||
|
||
```tsx
|
||
import { useFrame, useThree } from "@react-three/fiber";
|
||
import { PerspectiveCamera as ThreePerspectiveCamera } from "three";
|
||
import { useDirectorStore } from "../store/directorStore";
|
||
import { evaluateCameraTrackAtTime } from "../schema/timeline";
|
||
import { usePlaybackStore } from "./playbackStore";
|
||
|
||
/**
|
||
* 机位视图下:若当前激活相机有运镜轨道,每帧用插值结果覆盖渲染相机的
|
||
* position / lookAt(target) / fov,呈现真正的运镜画面。
|
||
* 挂在 <Canvas> 内;非机位视图或相机无轨道时为空操作。
|
||
*/
|
||
export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: string | null }) {
|
||
const camera = useThree((state) => state.camera);
|
||
const cameraTracks = useDirectorStore((state) => state.project.timeline.cameraTracks);
|
||
const playhead = usePlaybackStore((state) => state.playhead);
|
||
|
||
useFrame(() => {
|
||
if (!activeCameraId) return;
|
||
const track = cameraTracks.find((item) => item.cameraId === activeCameraId);
|
||
if (!track || track.keyframes.length === 0) return;
|
||
const value = evaluateCameraTrackAtTime(track, playhead);
|
||
if (!value) return;
|
||
|
||
const perspectiveCamera = camera as ThreePerspectiveCamera;
|
||
perspectiveCamera.position.set(value.position[0], value.position[1], value.position[2]);
|
||
perspectiveCamera.lookAt(value.target[0], value.target[1], value.target[2]);
|
||
if (Math.abs(perspectiveCamera.fov - value.fov) > 0.001) {
|
||
perspectiveCamera.fov = value.fov;
|
||
perspectiveCamera.updateProjectionMatrix();
|
||
}
|
||
});
|
||
|
||
return null;
|
||
}
|
||
```
|
||
|
||
> 时序说明:机位视图下场景里**没有** OrbitControls(`DirectorCanvas` 里 OrbitControls 仅在 `viewMode === "director"` 挂载),渲染相机由 drei `<PerspectiveCamera makeDefault>` 初始化。播放期间 directorStore 不变、组件不重渲,因此 `useFrame` 的覆盖不会被 React props 反复打断。fov 改动时需手动 `updateProjectionMatrix()`。
|
||
|
||
- [ ] **Step 2: 改 `SceneRoot.tsx`——`ViewportCameraRig` 加线框覆盖(导演视图运镜预览①)**
|
||
|
||
在 `SceneRoot.tsx` 顶部 import 区补:
|
||
```tsx
|
||
import { evaluateCameraTrackAtTime } from "../schema/timeline";
|
||
```
|
||
(`useFrame`、`usePlaybackStore` 已在 Task 5 Step 2 引入;若 Task 5 还没执行,则一并引入。)
|
||
|
||
在 `ViewportCameraRig` 组件内(`const selectObject = ...`、`const updateCamera = ...` 之后)加:
|
||
```tsx
|
||
const cameraTracks = useDirectorStore((state) => state.project.timeline.cameraTracks);
|
||
const playhead = usePlaybackStore((state) => state.playhead);
|
||
|
||
useFrame(() => {
|
||
const group = groupRef.current;
|
||
if (!group) return;
|
||
const track = cameraTracks.find((item) => item.cameraId === camera.id);
|
||
if (!track || track.keyframes.length === 0) return;
|
||
const value = evaluateCameraTrackAtTime(track, playhead);
|
||
if (!value) return;
|
||
group.position.set(value.position[0], value.position[1], value.position[2]);
|
||
group.quaternion.copy(getViewportCameraQuaternion(value.position, value.target));
|
||
});
|
||
```
|
||
|
||
> 导演视图的相机线框朝向由 `getViewportCameraQuaternion(position, target)` 决定,所以用插值后的 position/target 重算 quaternion。fov 不影响线框外观,不处理。
|
||
|
||
- [ ] **Step 3: 改 `DirectorCanvas.tsx`——挂载 `ActiveCameraPlaybackSync`**
|
||
|
||
顶部 import:
|
||
```tsx
|
||
import { ActiveCameraPlaybackSync } from "../runtime/ActiveCameraPlaybackSync";
|
||
```
|
||
|
||
在 `<Canvas>` 内、`<PlaybackFrameDriver />` 旁边加(`activeCamera` 已在组件里取到):
|
||
```tsx
|
||
<ActiveCameraPlaybackSync activeCameraId={activeCamera?.id ?? null} />
|
||
```
|
||
|
||
- [ ] **Step 4: 构建 + 类型检查**
|
||
|
||
Run: `pnpm run build`
|
||
Expected: 通过。
|
||
|
||
- [ ] **Step 5: 提交(按约定跳过 git)**
|
||
|
||
```bash
|
||
git add src/editor/runtime/ActiveCameraPlaybackSync.tsx src/editor/canvas/SceneRoot.tsx src/editor/canvas/DirectorCanvas.tsx
|
||
git commit -m "feat(timeline): add camera dolly playback in director and camera views"
|
||
```
|
||
> ⚠️ 跳过本步骤(本阶段不用 git)。
|
||
|
||
---
|
||
|
||
## Task 7: TimelinePanel UI(物体 + 相机轨道)
|
||
|
||
> UI 先做**功能可用版**(原生控件为主)。视觉还原(配色/间距/字号)后续按项目设计规范(`bms-design-spec` / `ui-design-spec`)单独过一遍,不在本计划范围。
|
||
|
||
**Files:**
|
||
- Create: `src/editor/panels/TimelinePanel.tsx`
|
||
- Test: `src/editor/panels/TimelinePanel.test.tsx`
|
||
|
||
### 关键帧编辑工作流(烘焙式,物体与相机一致)
|
||
|
||
为避免 `useFrame` 覆盖与 `TransformControls` 拖动冲突:
|
||
|
||
1. **打关键帧**:把对象/相机摆到目标状态 → 指针移到某时刻 → 点「打关键帧」→ 把当前静态值存为该时刻关键帧。
|
||
2. **改关键帧**:点轨道上的关键帧方块 → 自动把值写回对象/相机(视口显示该帧状态、暂停)→ 调整 → 点「更新选中关键帧」写回。
|
||
3. **删关键帧**:选中后点「删除选中关键帧」。
|
||
|
||
相机打帧约束:**只允许 `targetMode === "manual"` 的相机打运镜关键帧**(UI 禁用按钮 + 提示)。
|
||
|
||
- [ ] **Step 1: 写失败测试 `TimelinePanel.test.tsx`**
|
||
|
||
```tsx
|
||
import { render, screen, fireEvent } from "@testing-library/react";
|
||
import { useDirectorStore, createDefaultDirectorProject } from "../store/directorStore";
|
||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||
import { TimelinePanel } from "./TimelinePanel";
|
||
|
||
beforeEach(() => {
|
||
useDirectorStore.getState().replaceProject(createDefaultDirectorProject() as never);
|
||
usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5 });
|
||
});
|
||
|
||
it("renders play button and time readout", () => {
|
||
render(<TimelinePanel />);
|
||
expect(screen.getByRole("button", { name: "播放" })).toBeInTheDocument();
|
||
expect(screen.getByText(/0\.0s \/ 5\.0s/)).toBeInTheDocument();
|
||
});
|
||
|
||
it("toggles to pause when play button clicked", () => {
|
||
render(<TimelinePanel />);
|
||
fireEvent.click(screen.getByRole("button", { name: "播放" }));
|
||
expect(usePlaybackStore.getState().isPlaying).toBe(true);
|
||
expect(screen.getByRole("button", { name: "暂停" })).toBeInTheDocument();
|
||
});
|
||
|
||
it("disables add-keyframe button when no object is selected", () => {
|
||
render(<TimelinePanel />);
|
||
expect(screen.getByRole("button", { name: /在指针处打关键帧/ })).toBeDisabled();
|
||
});
|
||
|
||
it("adds an object keyframe for the selected object at the playhead", () => {
|
||
const objectId = useDirectorStore.getState().project.objects[0].id;
|
||
useDirectorStore.getState().selectObject(objectId);
|
||
usePlaybackStore.getState().setPlayhead(2);
|
||
|
||
render(<TimelinePanel />);
|
||
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
|
||
|
||
const track = useDirectorStore.getState().project.timeline.tracks.find((t) => t.objectId === objectId);
|
||
expect(track?.keyframes).toHaveLength(1);
|
||
expect(track?.keyframes[0].time).toBe(2);
|
||
});
|
||
|
||
it("adds a camera keyframe when a manual camera is selected", () => {
|
||
// 默认工程的相机是 manual
|
||
const cameraObject = useDirectorStore.getState().project.objects.find((o) => o.kind === "camera")!;
|
||
useDirectorStore.getState().selectObject(cameraObject.id);
|
||
usePlaybackStore.getState().setPlayhead(1);
|
||
|
||
render(<TimelinePanel />);
|
||
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
|
||
|
||
const cameraId = cameraObject.linkedCameraId!;
|
||
const track = useDirectorStore.getState().project.timeline.cameraTracks.find((t) => t.cameraId === cameraId);
|
||
expect(track?.keyframes).toHaveLength(1);
|
||
});
|
||
|
||
it("deletes a selected keyframe", () => {
|
||
const objectId = useDirectorStore.getState().project.objects[0].id;
|
||
useDirectorStore.getState().selectObject(objectId);
|
||
usePlaybackStore.getState().setPlayhead(1);
|
||
render(<TimelinePanel />);
|
||
fireEvent.click(screen.getByRole("button", { name: /在指针处打关键帧/ }));
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: /关键帧/ }));
|
||
fireEvent.click(screen.getByRole("button", { name: "删除选中关键帧" }));
|
||
|
||
expect(useDirectorStore.getState().project.timeline.tracks).toEqual([]);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测试确认失败**
|
||
|
||
Run: `pnpm test -- src/editor/panels/TimelinePanel.test.tsx`
|
||
Expected: FAIL(组件不存在)。
|
||
|
||
- [ ] **Step 3: 写实现 `TimelinePanel.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import type { CameraKeyframeValue, KeyframeTransformValue } from "../schema/timeline";
|
||
import { useDirectorStore } from "../store/directorStore";
|
||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||
|
||
interface SelectedKeyframe {
|
||
kind: "object" | "camera";
|
||
targetId: string;
|
||
keyframeId: string;
|
||
}
|
||
|
||
function formatTime(t: number) {
|
||
return `${t.toFixed(1)}s`;
|
||
}
|
||
|
||
export function TimelinePanel() {
|
||
const project = useDirectorStore((state) => state.project);
|
||
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
|
||
|
||
const addKeyframe = useDirectorStore((state) => state.addKeyframe);
|
||
const removeKeyframe = useDirectorStore((state) => state.removeKeyframe);
|
||
const updateKeyframeValue = useDirectorStore((state) => state.updateKeyframeValue);
|
||
const applyKeyframeToObjectTransform = useDirectorStore((state) => state.applyKeyframeToObjectTransform);
|
||
const addCameraKeyframe = useDirectorStore((state) => state.addCameraKeyframe);
|
||
const removeCameraKeyframe = useDirectorStore((state) => state.removeCameraKeyframe);
|
||
const updateCameraKeyframeValue = useDirectorStore((state) => state.updateCameraKeyframeValue);
|
||
const applyCameraKeyframeToShot = useDirectorStore((state) => state.applyCameraKeyframeToShot);
|
||
const setTimelineDuration = useDirectorStore((state) => state.setTimelineDuration);
|
||
|
||
const isPlaying = usePlaybackStore((state) => state.isPlaying);
|
||
const playhead = usePlaybackStore((state) => state.playhead);
|
||
const duration = usePlaybackStore((state) => state.duration);
|
||
const togglePlaying = usePlaybackStore((state) => state.togglePlaying);
|
||
const setPlaying = usePlaybackStore((state) => state.setPlaying);
|
||
const setPlayhead = usePlaybackStore((state) => state.setPlayhead);
|
||
|
||
const [selectedKeyframe, setSelectedKeyframe] = useState<SelectedKeyframe | null>(null);
|
||
|
||
const timeline = project.timeline;
|
||
|
||
// 选中目标解析:物体 or 相机
|
||
const selectedObject = project.objects.find((o) => o.id === selectedObjectId) ?? null;
|
||
const selectedCameraObject =
|
||
selectedObject?.kind === "camera" ? selectedObject : null;
|
||
const selectedShot = selectedCameraObject?.linkedCameraId
|
||
? project.cameras.find((c) => c.id === selectedCameraObject.linkedCameraId) ?? null
|
||
: null;
|
||
const canAddCameraKeyframe = Boolean(selectedShot && selectedShot.targetMode === "manual");
|
||
const selectionIsManualCamera = Boolean(selectedCameraObject);
|
||
|
||
function snapshotObjectTransform(): KeyframeTransformValue {
|
||
if (!selectedObject) throw new Error("no selected object");
|
||
return {
|
||
position: [...selectedObject.transform.position] as [number, number, number],
|
||
rotation: [...selectedObject.transform.rotation] as [number, number, number],
|
||
scale: [...selectedObject.transform.scale] as [number, number, number],
|
||
};
|
||
}
|
||
function snapshotShot(): CameraKeyframeValue {
|
||
if (!selectedShot) throw new Error("no selected shot");
|
||
return {
|
||
position: [...selectedShot.transform.position] as [number, number, number],
|
||
target: [...selectedShot.target] as [number, number, number],
|
||
fov: selectedShot.fov,
|
||
};
|
||
}
|
||
|
||
function handleAddKeyframe() {
|
||
if (selectionIsManualCamera) {
|
||
if (!selectedShot || selectedShot.targetMode !== "manual") return;
|
||
addCameraKeyframe(selectedShot.id, playhead, snapshotShot());
|
||
} else if (selectedObject) {
|
||
addKeyframe(selectedObject.id, playhead, snapshotObjectTransform());
|
||
}
|
||
}
|
||
|
||
function handleSelectObjectKeyframe(objectId: string, keyframeId: string, time: number, value: KeyframeTransformValue) {
|
||
setPlaying(false);
|
||
setPlayhead(time);
|
||
applyKeyframeToObjectTransform(objectId, value);
|
||
setSelectedKeyframe({ kind: "object", targetId: objectId, keyframeId });
|
||
}
|
||
function handleSelectCameraKeyframe(cameraId: string, keyframeId: string, time: number, value: CameraKeyframeValue) {
|
||
setPlaying(false);
|
||
setPlayhead(time);
|
||
applyCameraKeyframeToShot(cameraId, value);
|
||
setSelectedKeyframe({ kind: "camera", targetId: cameraId, keyframeId });
|
||
}
|
||
|
||
function handleUpdateSelected() {
|
||
if (!selectedKeyframe) return;
|
||
if (selectedKeyframe.kind === "object" && selectedObject) {
|
||
updateKeyframeValue(selectedKeyframe.targetId, selectedKeyframe.keyframeId, snapshotObjectTransform());
|
||
} else if (selectedKeyframe.kind === "camera" && selectedShot) {
|
||
updateCameraKeyframeValue(selectedKeyframe.targetId, selectedKeyframe.keyframeId, snapshotShot());
|
||
}
|
||
}
|
||
function handleDeleteSelected() {
|
||
if (!selectedKeyframe) return;
|
||
if (selectedKeyframe.kind === "object") {
|
||
removeKeyframe(selectedKeyframe.targetId, selectedKeyframe.keyframeId);
|
||
} else {
|
||
removeCameraKeyframe(selectedKeyframe.targetId, selectedKeyframe.keyframeId);
|
||
}
|
||
setSelectedKeyframe(null);
|
||
}
|
||
|
||
const addKeyframeDisabled = selectionIsManualCamera ? !canAddCameraKeyframe : !selectedObject;
|
||
const addKeyframeTitle = selectionIsManualCamera
|
||
? canAddCameraKeyframe
|
||
? `把相机「${selectedShot?.name}」当前机位记录为 ${formatTime(playhead)} 处的运镜关键帧`
|
||
: "锁定物体的相机(targetMode=object)不支持运镜关键帧"
|
||
: selectedObject
|
||
? `把「${selectedObject.name}」当前位置记录为 ${formatTime(playhead)} 处的关键帧`
|
||
: "请先选择一个对象或相机";
|
||
|
||
return (
|
||
<section className="panel-card timeline-panel" aria-label="时间轴">
|
||
<div className="timeline-toolbar">
|
||
<button type="button" onClick={togglePlaying}>
|
||
{isPlaying ? "暂停" : "播放"}
|
||
</button>
|
||
<span className="timeline-time-readout">
|
||
{formatTime(playhead)} / {formatTime(duration)}
|
||
</span>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={duration}
|
||
step={0.05}
|
||
value={playhead}
|
||
onChange={(event) => setPlayhead(Number(event.target.value))}
|
||
aria-label="时间指针"
|
||
/>
|
||
<label className="timeline-duration">
|
||
时长
|
||
<input
|
||
type="number"
|
||
min={0.1}
|
||
step={0.5}
|
||
value={duration}
|
||
onChange={(event) => setTimelineDuration(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<button type="button" disabled={addKeyframeDisabled} onClick={handleAddKeyframe} title={addKeyframeTitle}>
|
||
在指针处打关键帧
|
||
</button>
|
||
{selectedKeyframe ? (
|
||
<>
|
||
<button type="button" onClick={handleUpdateSelected}>更新选中关键帧</button>
|
||
<button type="button" onClick={handleDeleteSelected}>删除选中关键帧</button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="timeline-tracks">
|
||
{timeline.tracks.length === 0 && timeline.cameraTracks.length === 0 ? (
|
||
<p className="timeline-empty">还没有关键帧。选中对象或相机,摆好状态后点「在指针处打关键帧」。</p>
|
||
) : null}
|
||
|
||
{timeline.tracks.map((track) => {
|
||
const obj = project.objects.find((o) => o.id === track.objectId);
|
||
return (
|
||
<div className="timeline-track" key={track.objectId}>
|
||
<span className="timeline-track-label">{obj?.name ?? track.objectId}</span>
|
||
<div className="timeline-track-lane">
|
||
{track.keyframes.map((kf) => {
|
||
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
|
||
const isSelected =
|
||
selectedKeyframe?.kind === "object" &&
|
||
selectedKeyframe.targetId === track.objectId &&
|
||
selectedKeyframe.keyframeId === kf.id;
|
||
return (
|
||
<button
|
||
key={kf.id}
|
||
type="button"
|
||
className={`timeline-keyframe${isSelected ? " is-selected" : ""}`}
|
||
style={{ left: `${leftPercent}%` }}
|
||
aria-label={`关键帧 ${formatTime(kf.time)}`}
|
||
onClick={() => handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{timeline.cameraTracks.map((track) => {
|
||
const shot = project.cameras.find((c) => c.id === track.cameraId);
|
||
return (
|
||
<div className="timeline-track timeline-track-camera" key={track.cameraId}>
|
||
<span className="timeline-track-label">📷 {shot?.name ?? track.cameraId}</span>
|
||
<div className="timeline-track-lane">
|
||
{track.keyframes.map((kf) => {
|
||
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
|
||
const isSelected =
|
||
selectedKeyframe?.kind === "camera" &&
|
||
selectedKeyframe.targetId === track.cameraId &&
|
||
selectedKeyframe.keyframeId === kf.id;
|
||
return (
|
||
<button
|
||
key={kf.id}
|
||
type="button"
|
||
className={`timeline-keyframe timeline-keyframe-camera${isSelected ? " is-selected" : ""}`}
|
||
style={{ left: `${leftPercent}%` }}
|
||
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
|
||
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 加最小样式(功能优先)**
|
||
|
||
定位全局样式入口(运行 `grep -rl "panel-card" src/styles` 找到文件),追加:
|
||
```css
|
||
.timeline-panel { display: flex; flex-direction: column; gap: 8px; padding: 8px 12px; }
|
||
.timeline-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.timeline-toolbar input[type="range"] { flex: 1; min-width: 120px; }
|
||
.timeline-time-readout { font-variant-numeric: tabular-nums; min-width: 96px; }
|
||
.timeline-duration { display: inline-flex; align-items: center; gap: 4px; }
|
||
.timeline-duration input { width: 56px; }
|
||
.timeline-tracks { display: flex; flex-direction: column; gap: 4px; max-height: 180px; overflow-y: auto; }
|
||
.timeline-track { display: flex; align-items: center; gap: 8px; }
|
||
.timeline-track-label { width: 110px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.timeline-track-lane { position: relative; flex: 1; height: 20px; background: rgba(127, 127, 127, 0.15); border-radius: 4px; }
|
||
.timeline-keyframe { position: absolute; top: 50%; width: 12px; height: 12px; transform: translate(-50%, -50%); background: #4F8EF7; border: none; border-radius: 2px; padding: 0; cursor: pointer; }
|
||
.timeline-keyframe-camera { background: #12B886; }
|
||
.timeline-keyframe.is-selected { background: #F2A900; outline: 1px solid #fff; }
|
||
.timeline-empty { opacity: 0.7; margin: 4px 0; }
|
||
```
|
||
|
||
- [ ] **Step 5: 跑测试确认通过**
|
||
|
||
Run: `pnpm test -- src/editor/panels/TimelinePanel.test.tsx`
|
||
Expected: PASS。
|
||
|
||
- [ ] **Step 6: 提交(按约定跳过 git)**
|
||
|
||
```bash
|
||
git add src/editor/panels/TimelinePanel.tsx src/editor/panels/TimelinePanel.test.tsx src/styles
|
||
git commit -m "feat(timeline): add timeline panel with object and camera keyframe editing"
|
||
```
|
||
> ⚠️ 跳过本步骤(本阶段不用 git)。
|
||
|
||
---
|
||
|
||
## Task 8: 接入布局 + 全量构建 + 手测
|
||
|
||
**Files:**
|
||
- Modify: `src/app/layout/DirectorDeskShell.tsx`
|
||
|
||
- [ ] **Step 1: 把 TimelinePanel 挂到视口列底部**
|
||
|
||
在 `DirectorDeskShell.tsx` 顶部 import:
|
||
```tsx
|
||
import { TimelinePanel } from "../../editor/panels/TimelinePanel";
|
||
```
|
||
|
||
把 `<section className="viewport-column">` 改为:
|
||
```tsx
|
||
<section className="viewport-column" aria-label="3D视口">
|
||
<div className="viewport-stack">
|
||
{children}
|
||
<TimelinePanel />
|
||
</div>
|
||
</section>
|
||
```
|
||
|
||
并在全局样式里补:
|
||
```css
|
||
.viewport-stack { display: flex; flex-direction: column; height: 100%; }
|
||
.viewport-stack > :first-child { flex: 1; min-height: 0; }
|
||
```
|
||
|
||
- [ ] **Step 2: 全量测试**
|
||
|
||
Run: `pnpm test`
|
||
Expected: PASS(含原有用例 + 新增用例)。原有 8 个失败用例(模型库面板/画幅命中区/个别姿势/样式断言)若仍失败,**保持原状不算回归**——记录一下新旧失败数即可。
|
||
|
||
- [ ] **Step 3: 生产构建**
|
||
|
||
Run: `pnpm run build`
|
||
Expected: 通过(tsc + vite build)。
|
||
|
||
- [ ] **Step 4: 手测清单(`pnpm run dev`,浏览器 `http://127.0.0.1:5173/`)**
|
||
|
||
**物体关键帧:**
|
||
1. 底部出现时间轴面板,显示「播放」按钮和 `0.0s / 5.0s`。
|
||
2. 选中默认角色 → 用 gizmo 挪到某位置 → 指针拖到 `1.0s` → 点「在指针处打关键帧」→ 轨道出现蓝色方块。
|
||
3. 指针拖到 `3.0s` → 把角色挪到另一位置 → 再打关键帧。
|
||
4. 点「播放」:角色在 0–3 秒线性移动,到末尾循环回起点;播放中 gizmo 消失。
|
||
5. 拖动指针 scrub:角色停在对应插值位置。
|
||
6. 点轨道某关键帧:角色跳到该帧姿态、暂停、方块变橙;gizmo 调整位置 → 「更新选中关键帧」→ 再播放验证已更新。
|
||
7. 「删除选中关键帧」:方块消失;轨道空了则整行消失。
|
||
|
||
**相机运镜:**
|
||
8. 选中相机(导演视图点相机线框)→ 指针到 `0.5s` → 点「打关键帧」→ 相机轨道出现绿色方块。
|
||
9. 指针到 `3.5s` → 用 gizmo 把相机挪到另一个机位/调整 target/fov → 再打关键帧。
|
||
10. 点「播放」:**导演视图**里相机线框沿轨迹平滑移动;切到**机位视图**,能看到真正的运镜画面(推/拉/摇/变焦)。
|
||
11. 在机位视图 scrub 指针:运镜画面随之停在对应时刻。
|
||
12. 点相机轨道某关键帧 → 相机回到该帧机位、暂停 → 调整 → 「更新选中关键帧」。
|
||
|
||
**持久化与导入导出:**
|
||
13. 刷新页面:场景、物体关键帧、相机运镜关键帧都还在。
|
||
14. 「导出工程 JSON」→「导入工程 JSON」:物体 + 相机关键帧数据完整保留。
|
||
15. 导入一个旧的 v1 工程(无 timeline):能正常打开,timeline 自动补为空默认值,不报错。
|
||
|
||
- [ ] **Step 5: 提交(按约定跳过 git)**
|
||
|
||
```bash
|
||
git add src/app/layout/DirectorDeskShell.tsx src/styles
|
||
git commit -m "feat(timeline): mount timeline panel under the viewport"
|
||
```
|
||
> ⚠️ 跳过本步骤(本阶段不用 git)。
|
||
|
||
---
|
||
|
||
## Self-Review(计划自检)
|
||
|
||
**1. Spec coverage(范围覆盖)**
|
||
- 物体 position/rotation/scale 关键帧:Task 1 / 3 / 5 / 7。✅
|
||
- 相机运镜 position/target/fov 关键帧:Task 1(数据+插值)/ 3(actions)/ 6(两路径回放)/ 7(UI)。✅
|
||
- 两种运镜预览(导演视图线框 + 机位视图画面):Task 6 Step 2 + Step 1。✅
|
||
- 线性插值:Task 1 `evaluateKeyframes` / `lerpTransformValue` / `lerpCameraValue`。✅
|
||
- 时间轴 UI(播放/指针/时长/打帧/选删改,物体+相机):Task 7。✅
|
||
- 视口实时预览:Task 4 + 5 + 6。✅
|
||
- 仅 manual 相机支持运镜:Task 7 `canAddCameraKeyframe` + `addKeyframeDisabled`。✅
|
||
- 工程 JSON / localStorage 自动带 timeline:Task 2(schema+migration+replaceProject 走 migrate),复用现有持久化与 `serializeProject`。✅
|
||
- 排除项(姿势动画/视频导出/曲线编辑/四元数):均未出现在任何 task。✅
|
||
|
||
**2. Placeholder scan**
|
||
- 所有步骤含完整代码或精确命令;样式文件路径用 `grep -rl "panel-card" src/styles` 定位,非占位。✅
|
||
- 无 TBD / "类似上面" / "补充错误处理"。✅
|
||
|
||
**3. Type consistency**
|
||
- `KeyframeTransformValue` / `CameraKeyframeValue` 在 Task 1 定义,Task 3 actions、Task 7 UI 均用同名类型。✅
|
||
- 纯函数名:`timeline.ts` 导出 `upsertKeyframe/removeKeyframe/updateKeyframeTime/updateKeyframeValue` 与 `upsertCameraKeyframe/removeCameraKeyframe/updateCameraKeyframeTime/updateCameraKeyframeValue`;store 内为避免与 action 同名,import 时分别别名成 `removeTimelineKeyframe/updateTimelineKeyframeTime/...` 与 `removeTimelineCameraKeyframe/updateTimelineCameraKeyframeTime/...`(Task 2 Step 2 已列全 import;Task 3 Step 4 使用别名一致)。`upsertKeyframe` / `upsertCameraKeyframe` 在 action(`addKeyframe` / `addCameraKeyframe`)里直接用,不冲突。✅
|
||
- `evaluateTrackAtTime(track, time)` / `evaluateCameraTrackAtTime(track, time)` 签名在 Task 1、Task 5、Task 6 调用一致。✅
|
||
- `usePlaybackStore` 的 `advance/setPlayhead/setDuration/togglePlaying/setPlaying` 在 Task 4 定义,Task 5/6/7 调用一致。✅
|
||
- `Timeline` 含 `tracks` + `cameraTracks`,Task 1 默认值、Task 2 migration(`withTimeline` 补 `cameraTracks: []`)、所有测试断言一致。✅
|
||
- `applyCameraKeyframeToShot` 内部复用现有 `updateCamera`(传 `fov/target/transform`),与 `updateCamera` 已有"同步相机 object transform"行为一致,无需额外处理 linked object。✅
|
||
|
||
---
|
||
|
||
## 备注 / 已知限制
|
||
|
||
- **物体旋转用欧拉线性插值**:超过 ±180° 会"走远路"。MVP 可接受;未来可改四元数 `slerp`。
|
||
- **相机朝向用 target 线性插值**:target 穿过相机位置附近时可能抖动,属正常 lookAt 行为,MVP 不处理。
|
||
- **群众阵列(crowd)**:成员是独立 `DirectorObject`,逐个打关键帧可行但繁琐;本计划不为 crowd 做整组轨道。
|
||
- **`useFrame` 集成无单测**:R3F canvas 内逐帧逻辑难以在 vitest 直接测,靠 Task 8 手测兜底;纯逻辑(物体/相机插值、增删改)已被 Task 1 / Task 3 覆盖。
|
||
- **`targetMode === "object"` 相机**:不支持运镜关键帧(UI 禁用)。如未来需要,可在回放时把"锁定物体"的 target 解析为物体当前位置再叠加,本计划不做。
|
||
- **持久化体积**:关键帧数据量小,不会触发现有 localStorage 的 quota 容错分支问题。
|