diff --git a/.gitignore b/.gitignore index 975ee82..c765ccd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,3 @@ vite.config.js vite.config.d.ts release *.exe -docs \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-23-keyframe-animation.md b/docs/superpowers/plans/2026-07-23-keyframe-animation.md new file mode 100644 index 0000000..8f60764 --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-keyframe-animation.md @@ -0,0 +1,1670 @@ +# 关键帧动画(物体移动 + 镜头运镜)实现计划 + +> **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` | 挂载 `` 和 ``(机位视图运镜预览②) | +| `src/app/layout/DirectorDeskShell.tsx` | 视口列底部挂 `` | + +--- + +## 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: `npm 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(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 { + time: number; + value: V; +} +function evaluateKeyframes( + keyframes: TimedValue[], + 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: `npm 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; + 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: `npm 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: `npm 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: `npm 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: `npm 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((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: `npm 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 内部: + * 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} + + + ); +``` +改为: +```tsx + const showGizmo = selected && transformable && !isPlaying; + + if (!showGizmo) return node; + + return ( + <> + {node} + + + ); +``` + +- [ ] **Step 4: 改 `DirectorCanvas.tsx`——挂载驱动器** + +在 `DirectorCanvas.tsx` 顶部 import: +```tsx +import { PlaybackFrameDriver } from "../runtime/PlaybackFrameDriver"; +``` + +在 `` 内部、`` 之后加: +```tsx + +``` + +- [ ] **Step 5: 构建 + 类型检查** + +Run: `npm 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,呈现真正的运镜画面。 + * 挂在 内;非机位视图或相机无轨道时为空操作。 + */ +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 `` 初始化。播放期间 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"; +``` + +在 `` 内、`` 旁边加(`activeCamera` 已在组件里取到): +```tsx + +``` + +- [ ] **Step 4: 构建 + 类型检查** + +Run: `npm 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(); + expect(screen.getByRole("button", { name: "播放" })).toBeInTheDocument(); + expect(screen.getByText(/0\.0s \/ 5\.0s/)).toBeInTheDocument(); +}); + +it("toggles to pause when play button clicked", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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: `npm 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(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 ( +
+
+ + + {formatTime(playhead)} / {formatTime(duration)} + + setPlayhead(Number(event.target.value))} + aria-label="时间指针" + /> + + + {selectedKeyframe ? ( + <> + + + + ) : null} +
+ +
+ {timeline.tracks.length === 0 && timeline.cameraTracks.length === 0 ? ( +

还没有关键帧。选中对象或相机,摆好状态后点「在指针处打关键帧」。

+ ) : null} + + {timeline.tracks.map((track) => { + const obj = project.objects.find((o) => o.id === track.objectId); + return ( +
+ {obj?.name ?? track.objectId} +
+ {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 ( +
+
+ ); + })} + + {timeline.cameraTracks.map((track) => { + const shot = project.cameras.find((c) => c.id === track.cameraId); + return ( +
+ 📷 {shot?.name ?? track.cameraId} +
+ {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 ( +
+
+ ); + })} +
+
+ ); +} +``` + +- [ ] **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: `npm 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"; +``` + +把 `
` 改为: +```tsx +
+
+ {children} + +
+
+``` + +并在全局样式里补: +```css +.viewport-stack { display: flex; flex-direction: column; height: 100%; } +.viewport-stack > :first-child { flex: 1; min-height: 0; } +``` + +- [ ] **Step 2: 全量测试** + +Run: `npm test` +Expected: PASS(含原有用例 + 新增用例)。原有 8 个失败用例(模型库面板/画幅命中区/个别姿势/样式断言)若仍失败,**保持原状不算回归**——记录一下新旧失败数即可。 + +- [ ] **Step 3: 生产构建** + +Run: `npm run build` +Expected: 通过(tsc + vite build)。 + +- [ ] **Step 4: 手测清单(`npm 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 容错分支问题。 diff --git a/package-lock.json b/package-lock.json index 37d0d49..8eb8605 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@react-three/fiber": "^8.17.14", "camera-controls": "^2.9.0", "lucide-react": "^0.469.0", + "mp4-muxer": "^5.2.2", "react": "^18.3.1", "react-dom": "^18.3.1", "three": "^0.184.0", @@ -2369,6 +2370,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.19", + "resolved": "https://registry.npmmirror.com/@types/dom-webcodecs/-/dom-webcodecs-0.1.19.tgz", + "integrity": "sha512-+yFUIDUaASFz0vQpWBErPxun/Lb+F0TdrlTsj50gm7K797YzEx2TmV6fEiwciHyFNghlZYPgQXH5qRcwMHcJFQ==", + "license": "MIT" + }, "node_modules/@types/draco3d": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", @@ -2526,6 +2533,12 @@ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", "license": "MIT" }, + "node_modules/@types/wicg-file-system-access": { + "version": "2020.9.8", + "resolved": "https://registry.npmmirror.com/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz", + "integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==", + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -5949,6 +5962,17 @@ "node": ">=10" } }, + "node_modules/mp4-muxer": { + "version": "5.2.2", + "resolved": "https://registry.npmmirror.com/mp4-muxer/-/mp4-muxer-5.2.2.tgz", + "integrity": "sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==", + "deprecated": "This library is superseded by Mediabunny. Please migrate to it.", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "^0.1.6", + "@types/wicg-file-system-access": "^2020.9.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index cc77815..dd58446 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,9 @@ "target": [ { "target": "nsis", - "arch": ["x64"] + "arch": [ + "x64" + ] } ], "icon": "public/icon.png", @@ -57,6 +59,7 @@ "@react-three/fiber": "^8.17.14", "camera-controls": "^2.9.0", "lucide-react": "^0.469.0", + "mp4-muxer": "^5.2.2", "react": "^18.3.1", "react-dom": "^18.3.1", "three": "^0.184.0", diff --git a/src/editor/canvas/DirectorCanvas.tsx b/src/editor/canvas/DirectorCanvas.tsx index 9fdc958..a3f60e5 100644 --- a/src/editor/canvas/DirectorCanvas.tsx +++ b/src/editor/canvas/DirectorCanvas.tsx @@ -14,6 +14,7 @@ import { Euler, Matrix4, PerspectiveCamera as ThreePerspectiveCamera, Quaternion import type { Object3D } from "three"; import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; import { clearViewportCaptureHandler, setViewportCaptureHandler } from "../io/captureBridge"; +import { setVideoExportContext } from "../io/videoExportBridge"; import { buildScreenshotMeta, type ScreenshotResult } from "../io/screenshotExport"; import { useDirectorStore, type CameraShotSnapshot } from "../store/directorStore"; import { DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT, getCameraViewSnapshotFromShot } from "../schema/cameraGeometry"; @@ -481,6 +482,18 @@ function CanvasCaptureBridge({ return null; } +/** 把渲染三件套注册给视频导出逻辑(逐帧离线渲染用),模式仿 CanvasCaptureBridge */ +function VideoExportBridge() { + const { camera, gl, scene } = useThree(); + + useEffect(() => { + setVideoExportContext({ gl, scene, camera: camera as ThreePerspectiveCamera }); + return () => setVideoExportContext(null); + }, [camera, gl, scene]); + + return null; +} + function DirectorViewCameraSync({ controlsRef, snapshot, @@ -737,6 +750,7 @@ export function DirectorCanvas() { viewMode={viewMode} /> + diff --git a/src/editor/canvas/PlaybackTransformSync.tsx b/src/editor/canvas/PlaybackTransformSync.tsx index de54d1b..952cf6a 100644 --- a/src/editor/canvas/PlaybackTransformSync.tsx +++ b/src/editor/canvas/PlaybackTransformSync.tsx @@ -1,14 +1,11 @@ import { useFrame, useThree } from "@react-three/fiber"; import { useDirectorStore } from "../store/directorStore"; -import { evaluateCameraTrackAtTime, evaluateTrackAtTime } from "../schema/timeline"; import { usePlaybackStore } from "../runtime/playbackStore"; -import { getViewportCameraQuaternion } from "./SceneRoot"; +import { applyTimelineTransformsAtTime } from "../runtime/playbackTransforms"; /** * 挂在 内:每帧根据 playhead 把物体/相机线框的插值结果直接写到 - * 对应的 Three.js 对象。SceneRoot 的 group 通过 userData 标记身份: - * - directorObjectId → 物体(覆盖 position/rotation/scale) - * - directorCameraId → 导演视图相机线框(覆盖 position + 朝向 target) + * 对应的 Three.js 对象(逻辑在 playbackTransforms.ts,与视频导出共用)。 * 无轨道或无关键帧的对象保持静态,不被触碰。 */ export function PlaybackTransformSync() { @@ -19,39 +16,8 @@ export function PlaybackTransformSync() { const editingObjectId = usePlaybackStore((state) => state.editingObjectId); useFrame(() => { - if (tracks.length === 0 && cameraTracks.length === 0) return; - - const trackMap = new Map(tracks.map((track) => [track.objectId, track])); - const cameraTrackMap = new Map(cameraTracks.map((track) => [track.cameraId, track])); - - scene.traverse((object) => { - // 播放、scrub 时都按 playhead 插值覆盖;但跳过正在被 gizmo 拖拽的对象(editingObjectId), - // 否则 gizmo 改的值会被立刻覆盖回去,拖不动。 - const objectId = object.userData?.directorObjectId; - if (objectId && objectId !== editingObjectId) { - const track = trackMap.get(objectId); - if (track && track.keyframes.length > 0) { - const value = evaluateTrackAtTime(track, playhead); - if (value) { - object.position.set(value.position[0], value.position[1], value.position[2]); - object.rotation.set(value.rotation[0], value.rotation[1], value.rotation[2]); - object.scale.set(value.scale[0], value.scale[1], value.scale[2]); - } - } - } - - const cameraId = object.userData?.directorCameraId; - if (cameraId && cameraId !== editingObjectId) { - const track = cameraTrackMap.get(cameraId); - if (track && track.keyframes.length > 0) { - const value = evaluateCameraTrackAtTime(track, playhead); - if (value) { - object.position.set(value.position[0], value.position[1], value.position[2]); - object.quaternion.copy(getViewportCameraQuaternion(value.position, value.target)); - } - } - } - }); + // 播放、scrub 时都按 playhead 插值覆盖;editingObjectId 的跳过逻辑在共享函数里 + applyTimelineTransformsAtTime(scene, tracks, cameraTracks, playhead, editingObjectId); }); return null; diff --git a/src/editor/canvas/SceneRoot.tsx b/src/editor/canvas/SceneRoot.tsx index 6c9a5d1..07d55b5 100644 --- a/src/editor/canvas/SceneRoot.tsx +++ b/src/editor/canvas/SceneRoot.tsx @@ -476,6 +476,7 @@ function ObjectSceneNode({ } | null>(null); const updateObjectTransform = useDirectorStore((state) => state.updateObjectTransform); const isPlaying = usePlaybackStore((state) => state.isPlaying); + const motionTime = usePlaybackStore((state) => state.playhead); const isImportedModel = asset?.sourceType === "model"; const characterLabelKey = `${item.id}:${item.bodyType ?? ""}:${item.characterRig?.rigType ?? ""}`; const fallbackCharacterLabelY = @@ -539,6 +540,8 @@ function ObjectSceneNode({ color={item.color} onLabelAnchorYChange={handleCharacterLabelAnchorYChange} rigState={item.characterRig} + motionTime={motionTime} + characterId={item.id} /> {showLabels ? ( diff --git a/src/editor/io/videoExport.ts b/src/editor/io/videoExport.ts index 4a845d5..02d6667 100644 --- a/src/editor/io/videoExport.ts +++ b/src/editor/io/videoExport.ts @@ -1,8 +1,18 @@ +import { flushSync } from "react-dom"; +import { ArrayBufferTarget, Muxer } from "mp4-muxer"; +import { Vector2 } from "three"; import { useDirectorStore } from "../store/directorStore"; import { usePlaybackStore } from "../runtime/playbackStore"; +import { getVideoExportContext } from "./videoExportBridge"; +import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms"; +import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio"; export type VideoExportView = "director" | "camera"; +const EXPORT_FPS = 30; +/** 导出分辨率按「短边 = 720」基准缩放,画面比例取自 viewportAspectRatio(auto 时用视口当前比例) */ +const EXPORT_SHORT_EDGE = 720; + function pickMimeType(): { mimeType: string; ext: string } { if (typeof MediaRecorder === "undefined") return { mimeType: "", ext: "webm" }; const candidates = [ @@ -45,13 +55,181 @@ function waitNextFrame(): Promise { } /** - * 录制视口画面为视频: - * - 切到指定视角(director=上帝视角 / camera=机位视角) - * - 从 playhead 0 播放到时间轴末尾(stopAtEnd,不循环) - * - 优先 MP4(Chrome 120+ / Safari),不支持时回退 WebM(Chrome / Firefox) - * - 录完自动下载 + * 按视频比例(viewportAspectRatio)计算导出尺寸,短边 720,宽高取偶(编码器要求)。 + * 「自动」也固定按 16:9 导出 —— 导出画面比例绝不受视口布局(如时间轴拖高压扁视口)影响, + * 否则视口是扁的时导出也会跟着扁。 */ -export async function exportVideo(view: VideoExportView): Promise { +export function computeExportSize( + aspectRatioId: ReturnType["viewportAspectRatio"] +): { width: number; height: number } { + const aspect = getViewportAspectRatioValue(aspectRatioId) ?? 16 / 9; + let width: number; + let height: number; + if (aspect >= 1) { + height = EXPORT_SHORT_EDGE; + width = Math.round(height * aspect); + } else { + width = EXPORT_SHORT_EDGE; + height = Math.round(width / aspect); + } + return { width: width + (width % 2), height: height + (height % 2) }; +} + +async function pickVideoEncoderConfig(width: number, height: number) { + const candidates = [ + { codec: "avc1.640028" }, // High 4.0 + { codec: "avc1.4d0028" }, // Main 4.0 + { codec: "avc1.42001f" }, // Baseline 3.1 + ]; + const bitrate = Math.round(width * height * EXPORT_FPS * 0.15); // ≈6Mbps @720p30 + for (const { codec } of candidates) { + const config: VideoEncoderConfig = { + codec, + width, + height, + bitrate, + framerate: EXPORT_FPS, + }; + try { + const support = await VideoEncoder.isConfigSupported(config); + if (support.supported) return config; + } catch { + // 继续尝试下一个候选 + } + } + return null; +} + +/** + * 逐帧离线导出(快,不用实时播放一遍): + * - 渲染分辨率固定为视频比例(短边 720),不受视口布局影响 —— 时间轴拖多高画面都不会变扁 + * - 每帧直接 setPlayhead(t) + 手动渲染 + WebCodecs 编码,渲染多快导多快 + * - H.264 编码进 MP4(Chrome / Electron 均支持) + */ +async function exportVideoFrameByFrame(view: VideoExportView): Promise { + const bridge = getVideoExportContext(); + if (!bridge) throw new Error("找不到视口渲染上下文"); + const { gl, scene, camera } = bridge; + const canvas = gl.domElement; + + const directorStore = useDirectorStore.getState(); + const { width, height } = computeExportSize(directorStore.viewportAspectRatio); + const duration = directorStore.project.timeline.duration; + const totalFrames = Math.max(1, Math.round(duration * EXPORT_FPS)); + + const encoderConfig = await pickVideoEncoderConfig(width, height); + if (!encoderConfig) throw new Error("当前浏览器不支持 H.264 编码,请用最新版 Chrome / Edge"); + + // 保存现场:导出完原样恢复(视角 / 指针 / 渲染尺寸 / 相机比例) + const previousViewMode = directorStore.viewMode; + const previousPlayhead = usePlaybackStore.getState().playhead; + const previousPlaying = usePlaybackStore.getState().isPlaying; + const previousSize = new Vector2(); + gl.getSize(previousSize); + const previousPixelRatio = gl.getPixelRatio(); + const previousAspect = camera.aspect; + + const muxer = new Muxer({ + target: new ArrayBufferTarget(), + video: { codec: "avc", width, height }, + fastStart: "in-memory", + }); + let encodeError: unknown = null; + const encoder = new VideoEncoder({ + output: (chunk, meta) => muxer.addVideoChunk(chunk, meta), + error: (error) => { + encodeError = encodeError ?? error; + }, + }); + encoder.configure(encoderConfig); + + // 切到导出状态:固定分辨率渲染(不改 CSS 尺寸,canvas 加 object-fit: contain 保持显示不变形) + usePlaybackStore.getState().setPlaying(false); + directorStore.setViewMode(view); + canvas.classList.add("is-video-exporting"); + gl.setPixelRatio(1); + gl.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + + // 切视角会让 R3F 更换渲染相机(机位视角挂载 drei PerspectiveCamera), + // 等一拍让 VideoExportBridge 重新注册最新相机,导出循环里也每帧取最新引用 + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + const frameDurationUs = Math.round(1_000_000 / EXPORT_FPS); + try { + for (let frameIndex = 0; frameIndex <= totalFrames; frameIndex += 1) { + if (encodeError) throw encodeError; + const time = Math.min(frameIndex / EXPORT_FPS, duration); + // 相机可能因视角切换被替换,每帧从桥取最新;并保证导出画面比例 + const frameCamera = getVideoExportContext()?.camera ?? camera; + frameCamera.aspect = width / height; + frameCamera.updateProjectionMatrix(); + + // 同步推进 playhead 让 React 侧内容(角色动作等)立刻更新到 t + flushSync(() => { + usePlaybackStore.getState().setPlayhead(time); + }); + // 手动应用物体 / 相机运镜插值(与预览的 useFrame 同步逻辑共用) + applyTimelineTransformsAtTime( + scene, + directorStore.project.timeline.tracks, + directorStore.project.timeline.cameraTracks, + time, + usePlaybackStore.getState().editingObjectId + ); + applyActiveCameraViewAtTime({ + camera: frameCamera, + cameraTracks: directorStore.project.timeline.cameraTracks, + activeCameraId: directorStore.project.activeCameraId, + playhead: time, + viewMode: view, + editingObjectId: usePlaybackStore.getState().editingObjectId, + }); + + gl.render(scene, frameCamera); + + const frame = new VideoFrame(canvas, { + timestamp: frameIndex * frameDurationUs, + duration: frameDurationUs, + }); + encoder.encode(frame, { keyFrame: frameIndex % (EXPORT_FPS * 2) === 0 }); + frame.close(); + + // 背压:编码队列太深时等一等,防止内存堆积 + while (encoder.encodeQueueSize > 8) { + await new Promise((resolve) => window.setTimeout(resolve, 4)); + } + // 让出主线程,保持界面可响应(进度画面快进效果) + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + + await encoder.flush(); + muxer.finalize(); + if (encodeError) throw encodeError; + + const blob = new Blob([muxer.target.buffer], { type: "video/mp4" }); + const viewLabel = view === "director" ? "上帝视角" : "机位视角"; + downloadBlob(blob, `director-desk-${viewLabel}-${Date.now()}.mp4`); + } finally { + encoder.close(); + canvas.classList.remove("is-video-exporting"); + gl.setPixelRatio(previousPixelRatio); + gl.setSize(previousSize.x, previousSize.y, false); + const restoreCamera = getVideoExportContext()?.camera ?? camera; + restoreCamera.aspect = previousAspect; + restoreCamera.updateProjectionMatrix(); + directorStore.setViewMode(previousViewMode); + usePlaybackStore.getState().setPlaying(previousPlaying); + usePlaybackStore.getState().setPlayhead(previousPlayhead); + } +} + +/** + * 旧版实时录制(MediaRecorder 兜底路径):只在浏览器不支持 WebCodecs 时使用。 + * 从 playhead 0 播放到时间轴末尾录屏,耗时 = 视频时长。 + */ +async function exportVideoWithMediaRecorder(view: VideoExportView): Promise { const canvas = findViewportCanvas(); if (!canvas) throw new Error("找不到视口画布"); if (typeof MediaRecorder === "undefined") { @@ -118,3 +296,15 @@ export async function exportVideo(view: VideoExportView): Promise { const blob = new Blob(chunks, { type: mimeType || "video/webm" }); downloadBlob(blob, `director-desk-${viewLabel}-${Date.now()}.${ext}`); } + +/** + * 导出视频:优先 WebCodecs 逐帧快速导出(MP4,固定分辨率,不受视口布局影响); + * 浏览器不支持时回退 MediaRecorder 实时录屏(WebM/MP4,耗时 = 视频时长)。 + */ +export async function exportVideo(view: VideoExportView): Promise { + if (typeof VideoEncoder !== "undefined") { + await exportVideoFrameByFrame(view); + return; + } + await exportVideoWithMediaRecorder(view); +} diff --git a/src/editor/io/videoExportBridge.ts b/src/editor/io/videoExportBridge.ts new file mode 100644 index 0000000..e7cd26a --- /dev/null +++ b/src/editor/io/videoExportBridge.ts @@ -0,0 +1,22 @@ +import type { Object3D, PerspectiveCamera, WebGLRenderer } from "three"; + +/** + * 把 R3F 内部的渲染三件套(renderer / scene / camera)暴露给 + * Canvas 外的视频导出逻辑(videoExport.ts),供逐帧离线渲染。 + * 模式与 captureBridge.ts 一致:Canvas 内的桥组件注册,导出方读取。 + */ +export interface VideoExportContext { + gl: WebGLRenderer; + scene: Object3D; + camera: PerspectiveCamera; +} + +let videoExportContext: VideoExportContext | null = null; + +export function setVideoExportContext(context: VideoExportContext | null) { + videoExportContext = context; +} + +export function getVideoExportContext() { + return videoExportContext; +} diff --git a/src/editor/panels/CharacterPanel.test.tsx b/src/editor/panels/CharacterPanel.test.tsx index 455d383..42e1914 100644 --- a/src/editor/panels/CharacterPanel.test.tsx +++ b/src/editor/panels/CharacterPanel.test.tsx @@ -2,6 +2,8 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach } from "vitest"; import { createInitialDirectorState, useDirectorStore } from "../store/directorStore"; +import { usePlaybackStore } from "../runtime/playbackStore"; +import { hashIdToPhase } from "../runtime/mannequin/motion"; import { CharacterPanel } from "./CharacterPanel"; beforeEach(() => { @@ -157,3 +159,42 @@ it("applies pose presets to every member in a selected crowd group", async () => expect(new Set(crowdMembers.map((item) => item.characterRig?.posePresetId))).toEqual(new Set(["t-pose"])); expect(new Set(crowdMembers.map((item) => item.characterRig?.controls["leftShoulder.spread"]))).toEqual(new Set([-70])); }); + +it("动作开启时姿势滑杆渲染动作默认数据(偏移 0 = 动作当前值)", async () => { + const user = userEvent.setup(); + // 把播放头对到 θL=π/2(左髋前摆峰值 25°) + const phase = hashIdToPhase("char_default_a"); + const t = (Math.PI / 2 - phase) / (Math.PI * 2); + usePlaybackStore.setState({ playhead: t }); + useDirectorStore.getState().setMotion("char_default_a", "walk"); + + render(); + + await user.click(screen.getByRole("button", { name: "姿势" })); + + expect(screen.getByLabelText("左髋 · 前抬")).toHaveValue(25); + expect(screen.getByLabelText("右髋 · 前抬")).toHaveValue(-12); + // 左膝着地伸直 + expect(screen.getByLabelText("左膝 · 弯曲")).toHaveValue(0); +}); + +it("动作开启时拖动滑杆写入的是偏移而非绝对值", async () => { + const user = userEvent.setup(); + const phase = hashIdToPhase("char_default_a"); + const t = (Math.PI / 2 - phase) / (Math.PI * 2); + usePlaybackStore.setState({ playhead: t }); + useDirectorStore.getState().setMotion("char_default_a", "walk"); + + render(); + + await user.click(screen.getByRole("button", { name: "姿势" })); + await user.clear(screen.getByLabelText("左髋 · 前抬")); + await user.type(screen.getByLabelText("左髋 · 前抬"), "40"); + + // 40 = 动作值 25 + 偏移 15(度数↔弧度往返有浮点尾差,用 toBeCloseTo) + expect( + useDirectorStore.getState().project.objects.find((item) => item.id === "char_default_a")?.characterRig?.controls[ + "leftHip.pitch" + ] + ).toBeCloseTo(15); +}); diff --git a/src/editor/panels/CharacterPanel.tsx b/src/editor/panels/CharacterPanel.tsx index b2711c5..43458b5 100644 --- a/src/editor/panels/CharacterPanel.tsx +++ b/src/editor/panels/CharacterPanel.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { InspectorAxisGroup, + InspectorCheckboxField, InspectorColorField, InspectorPanel, InspectorRangeNumberField, @@ -9,13 +10,18 @@ import { } from "./InspectorControls"; import { MANNEQUIN_POSE_PRESETS } from "../presets/mannequinPosePresets"; import { getCrowdAnchorTransform, useDirectorStore } from "../store/directorStore"; +import { MOTION_OPTIONS, computeMotionOverrides } from "../runtime/mannequin/motion"; +import { usePlaybackStore } from "../runtime/playbackStore"; +import type { CharacterMotionState, MotionKind } from "../schema/directorProject"; function replaceAxis(tuple: [number, number, number], axis: 0 | 1 | 2, value: number): [number, number, number] { return tuple.map((item, index) => (index === axis ? value : item)) as [number, number, number]; } +const DEFAULT_MOTION: CharacterMotionState = { kind: "none", speed: 1, amplitude: 1, enabled: true }; + export function CharacterPanel() { - const [activeTab, setActiveTab] = useState<"properties" | "pose">("properties"); + const [activeTab, setActiveTab] = useState<"properties" | "pose" | "motion">("properties"); const selectedCrowdId = useDirectorStore((state) => state.selectedCrowdId); const selectedObjectId = useDirectorStore((state) => state.selectedObjectId); const objects = useDirectorStore((state) => state.project.objects); @@ -31,6 +37,15 @@ export function CharacterPanel() { const applyCrowdPosePreset = useDirectorStore((state) => state.applyCrowdPosePreset); const updatePoseControl = useDirectorStore((state) => state.updatePoseControl); const updateCrowdPoseControl = useDirectorStore((state) => state.updateCrowdPoseControl); + const setMotion = useDirectorStore((state) => state.setMotion); + const setCrowdMotion = useDirectorStore((state) => state.setCrowdMotion); + const setMotionSpeed = useDirectorStore((state) => state.setMotionSpeed); + const setCrowdMotionSpeed = useDirectorStore((state) => state.setCrowdMotionSpeed); + const setMotionAmplitude = useDirectorStore((state) => state.setMotionAmplitude); + const setCrowdMotionAmplitude = useDirectorStore((state) => state.setCrowdMotionAmplitude); + const setMotionEnabled = useDirectorStore((state) => state.setMotionEnabled); + const setCrowdMotionEnabled = useDirectorStore((state) => state.setCrowdMotionEnabled); + const playhead = usePlaybackStore((state) => state.playhead); const selection = useMemo(() => { const role = objects.find((item) => item.id === selectedObjectId && item.kind === "character"); @@ -71,79 +86,118 @@ export function CharacterPanel() { const roleColor = selection.color; const transform = selection.crowdAnchor; const isCrowd = selection.mode === "crowd"; - const poseGroups = [ + const motion: CharacterMotionState = role.characterRig?.motion ?? DEFAULT_MOTION; + const isMotionActive = motion.kind !== "none" && motion.enabled; + // 动作当前时刻(播放头)的关节值(度数域)——姿势滑杆显示「动作值 + 偏移」, + // 偏移为 0 时滑杆显示的就是动作默认数据本身;拖动时间轴时实时跟随 + const motionDegrees: Record = {}; + if (isMotionActive) { + const overrides = computeMotionOverrides({ motion, time: playhead, characterId: role.id }); + for (const [key, radians] of Object.entries(overrides)) { + if (typeof radians === "number" && Number.isFinite(radians)) { + motionDegrees[key] = (radians * 180) / Math.PI; + } + } + } + // 两级结构:一级 = 身体区域(可折叠),二级 = 区域内的部位分组 + 滑杆 + const poseSections = [ { - title: "身体", - controls: [ - { key: "body.pitch", label: "前倾" }, - { key: "body.yaw", label: "转身" }, - { key: "body.roll", label: "侧倾" }, + title: "核心", + groups: [ + { + title: "身体", + controls: [ + { key: "body.pitch", label: "前倾" }, + { key: "body.yaw", label: "转身" }, + { key: "body.roll", label: "侧倾" }, + ], + }, + { + title: "躯干", + controls: [ + { key: "torso.pitch", label: "前倾" }, + { key: "torso.yaw", label: "扭转" }, + { key: "torso.roll", label: "侧倾" }, + ], + }, + { + title: "头部", + controls: [ + { key: "head.pitch", label: "点头" }, + { key: "head.yaw", label: "转头" }, + { key: "head.roll", label: "歪头" }, + ], + }, ], }, { - title: "躯干", - controls: [ - { key: "torso.pitch", label: "前倾" }, - { key: "torso.yaw", label: "扭转" }, - { key: "torso.roll", label: "侧倾" }, + title: "左臂", + groups: [ + { + title: "左肩", + controls: [ + { key: "leftShoulder.pitch", label: "前举" }, + { key: "leftShoulder.spread", label: "外展" }, + { key: "leftShoulder.twist", label: "扭转" }, + ], + }, + { + title: "左肘", + controls: [{ key: "leftElbow.bend", label: "弯曲" }], + }, ], }, { - title: "头部", - controls: [ - { key: "head.pitch", label: "点头" }, - { key: "head.yaw", label: "转头" }, - { key: "head.roll", label: "歪头" }, + title: "右臂", + groups: [ + { + title: "右肩", + controls: [ + { key: "rightShoulder.pitch", label: "前举" }, + { key: "rightShoulder.spread", label: "外展" }, + { key: "rightShoulder.twist", label: "扭转" }, + ], + }, + { + title: "右肘", + controls: [{ key: "rightElbow.bend", label: "弯曲" }], + }, ], }, { - title: "左肩", - controls: [ - { key: "leftShoulder.pitch", label: "前举" }, - { key: "leftShoulder.spread", label: "外展" }, - { key: "leftShoulder.twist", label: "扭转" }, + title: "左腿", + groups: [ + { + title: "左髋", + controls: [ + { key: "leftHip.pitch", label: "前抬" }, + { key: "leftHip.spread", label: "外展" }, + { key: "leftHip.twist", label: "扭转" }, + ], + }, + { + title: "左膝", + controls: [{ key: "leftKnee.bend", label: "弯曲" }], + }, ], }, { - title: "右肩", - controls: [ - { key: "rightShoulder.pitch", label: "前举" }, - { key: "rightShoulder.spread", label: "外展" }, - { key: "rightShoulder.twist", label: "扭转" }, + title: "右腿", + groups: [ + { + title: "右髋", + controls: [ + { key: "rightHip.pitch", label: "前抬" }, + { key: "rightHip.spread", label: "外展" }, + { key: "rightHip.twist", label: "扭转" }, + ], + }, + { + title: "右膝", + controls: [{ key: "rightKnee.bend", label: "弯曲" }], + }, ], }, - { - title: "左肘", - controls: [{ key: "leftElbow.bend", label: "弯曲" }], - }, - { - title: "右肘", - controls: [{ key: "rightElbow.bend", label: "弯曲" }], - }, - { - title: "左髋", - controls: [ - { key: "leftHip.pitch", label: "前抬" }, - { key: "leftHip.spread", label: "外展" }, - { key: "leftHip.twist", label: "扭转" }, - ], - }, - { - title: "右髋", - controls: [ - { key: "rightHip.pitch", label: "前抬" }, - { key: "rightHip.spread", label: "外展" }, - { key: "rightHip.twist", label: "扭转" }, - ], - }, - { - title: "左膝", - controls: [{ key: "leftKnee.bend", label: "弯曲" }], - }, - { - title: "右膝", - controls: [{ key: "rightKnee.bend", label: "弯曲" }], - }, ] as const; return ( @@ -154,6 +208,7 @@ export function CharacterPanel() { tabs={[ { label: "属性", active: activeTab === "properties", onClick: () => setActiveTab("properties") }, { label: "姿势", active: activeTab === "pose", onClick: () => setActiveTab("pose") }, + { label: "动作", active: activeTab === "motion", onClick: () => setActiveTab("motion") }, ]} > {activeTab === "properties" ? ( @@ -333,7 +388,7 @@ export function CharacterPanel() { } /> - ) : ( + ) : activeTab === "pose" ? ( {role.characterRig ? ( <> @@ -354,36 +409,147 @@ export function CharacterPanel() { ))} -
- {poseGroups.map((group) => ( -
-

{group.title}

- {group.controls.map((control) => ( - - isCrowd && selection.crowdId - ? updateCrowdPoseControl(selection.crowdId, control.key, Number(value)) - : updatePoseControl(role.id, control.key, Number(value)) - } - /> - ))} -
+
+ {poseSections.map((section) => ( +
+ {section.title} +
+ {section.groups.map((group) => ( +
+

{group.title}

+ {group.controls.map((control) => { + const motionDeg = motionDegrees[control.key] ?? 0; + const offset = role.characterRig?.controls[control.key] ?? 0; + const displayValue = Math.round(Math.min(90, Math.max(-90, motionDeg + offset))); + return ( + { + // 滑杆值 = 动作当前值 + 偏移 → 回写时反算偏移 + const nextOffset = Number(value) - motionDeg; + if (isCrowd && selection.crowdId) { + updateCrowdPoseControl(selection.crowdId, control.key, nextOffset); + return; + } + updatePoseControl(role.id, control.key, nextOffset); + }} + /> + ); + })} +
+ ))} +
+
))}
+ {isMotionActive ? ( +

+ 滑杆显示「动作当前值 + 偏移」,随时间轴指针实时更新;拖动滑杆即设置该关节偏移。应用姿势预设会关闭动作并整身替换。 +

+ ) : null} ) : (

该模型未识别到标准 humanoid 骨骼,暂不支持姿势编辑。

)} + ) : ( + + {role.characterRig ? ( + <> +
+ 动作类型 +
+ {MOTION_OPTIONS.map((option) => ( + + ))} +
+
+ { + const next = Number(value); + if (isCrowd && selection.crowdId) { + setCrowdMotionSpeed(selection.crowdId, next); + return; + } + setMotionSpeed(role.id, next); + }} + /> + { + const next = Number(value); + if (isCrowd && selection.crowdId) { + setCrowdMotionAmplitude(selection.crowdId, next); + return; + } + setMotionAmplitude(role.id, next); + }} + /> + { + if (isCrowd && selection.crowdId) { + setCrowdMotionEnabled(selection.crowdId, checked); + return; + } + setMotionEnabled(role.id, checked); + }} + /> + {motion.kind === "idle" ? ( +

闲置动作:当前仅作为配置项保留,关节驱动在下一阶段接入。

+ ) : motion.kind === "run" ? ( +

跑步动作:切换动作时姿势已重置为站立基础态;可在「姿势」标签对各部位叠加偏移微调。

+ ) : motion.kind === "walk" ? ( +

走路动作:切换动作时姿势已重置为站立基础态;可在「姿势」标签对各部位叠加偏移微调。

+ ) : null} + + ) : ( +

该模型未识别到标准 humanoid 骨骼,暂不支持基础动作。

+ )} +
)} ); diff --git a/src/editor/panels/InspectorControls.tsx b/src/editor/panels/InspectorControls.tsx index aa74b36..8a08e8a 100644 --- a/src/editor/panels/InspectorControls.tsx +++ b/src/editor/panels/InspectorControls.tsx @@ -53,6 +53,7 @@ type RangeNumberFieldProps = { min: string | number; max: string | number; step: string | number; + disabled?: boolean; }; type InspectorSelectOption = { @@ -437,6 +438,7 @@ export function InspectorRangeNumberField({ min, max, step, + disabled, }: RangeNumberFieldProps) { const rangeDragCleanupRef = useRef<(() => void) | null>(null); const { beginInteraction, endInteraction } = useUndoBatchInteraction(); @@ -459,12 +461,13 @@ export function InspectorRangeNumberField({ } return ( -
+
{label}
); } + +export function InspectorCheckboxField({ + label, + ariaLabel, + checked, + onChange, + disabled, +}: { + label: string; + ariaLabel: string; + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +}) { + const { beginInteraction, endInteraction } = useUndoBatchInteraction(); + + return ( + + ); +} diff --git a/src/editor/panels/TimelinePanel.test.tsx b/src/editor/panels/TimelinePanel.test.tsx index 93d02b4..0530d53 100644 --- a/src/editor/panels/TimelinePanel.test.tsx +++ b/src/editor/panels/TimelinePanel.test.tsx @@ -91,3 +91,40 @@ it("clears all keyframes via the clear-all button", () => { expect(useDirectorStore.getState().project.timeline.tracks).toEqual([]); }); + +it("grows the timeline panel when dragging the top resizer upward", () => { + const { container } = render(); + const panel = container.querySelector(".timeline-panel")!; + expect(panel.style.height).toBe("300px"); + + const resizer = screen.getByRole("separator", { name: "拖动调整时间轴高度" }); + fireEvent.pointerDown(resizer, { clientY: 500, pointerId: 1 }); + fireEvent.pointerMove(resizer, { clientY: 440, pointerId: 1 }); // 向上拖 60px + fireEvent.pointerUp(resizer, { clientY: 440, pointerId: 1 }); + + expect(panel.style.height).toBe("360px"); +}); + +it("clamps the timeline height to a minimum when dragging downward far", () => { + const { container } = render(); + const panel = container.querySelector(".timeline-panel")!; + + const resizer = screen.getByRole("separator", { name: "拖动调整时间轴高度" }); + fireEvent.pointerDown(resizer, { clientY: 500, pointerId: 1 }); + fireEvent.pointerMove(resizer, { clientY: 900, pointerId: 1 }); // 向下拖 400px + fireEvent.pointerUp(resizer, { clientY: 900, pointerId: 1 }); + + expect(panel.style.height).toBe("160px"); +}); + +it("clamps the timeline height to the fixed 420px maximum when dragging upward far", () => { + const { container } = render(); + const panel = container.querySelector(".timeline-panel")!; + + const resizer = screen.getByRole("separator", { name: "拖动调整时间轴高度" }); + fireEvent.pointerDown(resizer, { clientY: 500, pointerId: 1 }); + fireEvent.pointerMove(resizer, { clientY: 100, pointerId: 1 }); // 向上拖 400px → 700 超上限 + fireEvent.pointerUp(resizer, { clientY: 100, pointerId: 1 }); + + expect(panel.style.height).toBe("420px"); +}); diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx index c2e7e86..9e13728 100644 --- a/src/editor/panels/TimelinePanel.tsx +++ b/src/editor/panels/TimelinePanel.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent } from "react"; import type { CameraKeyframeValue, KeyframeTransformValue } from "../schema/timeline"; import { useDirectorStore } from "../store/directorStore"; import { usePlaybackStore } from "../runtime/playbackStore"; @@ -10,6 +11,27 @@ interface SelectedKeyframe { keyframeId: string; } +const TIMELINE_MIN_HEIGHT = 160; +const TIMELINE_DEFAULT_HEIGHT = 300; +/** 面板最大高度固定值:内容再多也在面板内部滚动,不把 3D 视口压没 */ +const TIMELINE_MAX_HEIGHT = 420; +const TIMELINE_HEIGHT_STORAGE_KEY = "director.timeline.height"; + +function clampTimelineHeight(height: number) { + return Math.min(Math.max(Math.round(height), TIMELINE_MIN_HEIGHT), TIMELINE_MAX_HEIGHT); +} + +function loadTimelineHeight() { + try { + const raw = window.localStorage.getItem(TIMELINE_HEIGHT_STORAGE_KEY); + if (!raw) return TIMELINE_DEFAULT_HEIGHT; + const value = Number(raw); + return Number.isFinite(value) ? clampTimelineHeight(value) : TIMELINE_DEFAULT_HEIGHT; + } catch { + return TIMELINE_DEFAULT_HEIGHT; + } +} + function formatTime(t: number) { return `${t.toFixed(1)}s`; } @@ -39,6 +61,52 @@ export function TimelinePanel() { const [selectedKeyframe, setSelectedKeyframe] = useState(null); const [isRecording, setIsRecording] = useState(false); + // 时间轴面板高度:可拖动顶部手柄调整(向上拖变大),并持久化到 localStorage + const [timelineHeight, setTimelineHeight] = useState(loadTimelineHeight); + const [isResizing, setIsResizing] = useState(false); + const resizeStartRef = useRef<{ pointerY: number; startHeight: number } | null>(null); + const timelineHeightRef = useRef(timelineHeight); + timelineHeightRef.current = timelineHeight; + + useEffect(() => { + if (!isResizing) return; + document.body.classList.add("is-resizing-timeline"); + return () => document.body.classList.remove("is-resizing-timeline"); + }, [isResizing]); + + function handleResizeStart(event: ReactPointerEvent) { + event.preventDefault(); + resizeStartRef.current = { pointerY: event.clientY, startHeight: timelineHeight }; + setIsResizing(true); + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // 某些环境(如 jsdom)不支持 pointer capture,忽略后仍可通过 move/up 事件拖拽 + } + } + + function handleResizeMove(event: ReactPointerEvent) { + const start = resizeStartRef.current; + if (!start) return; + setTimelineHeight(clampTimelineHeight(start.startHeight + (start.pointerY - event.clientY))); + } + + function handleResizeEnd(event: ReactPointerEvent) { + if (!resizeStartRef.current) return; + resizeStartRef.current = null; + setIsResizing(false); + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // 同上,忽略不支持的环境 + } + try { + window.localStorage.setItem(TIMELINE_HEIGHT_STORAGE_KEY, String(timelineHeightRef.current)); + } catch { + // 持久化失败(如隐私模式)不影响功能 + } + } + const timeline = project.timeline; // 选中目标解析:物体 or 相机 @@ -138,7 +206,22 @@ export function TimelinePanel() { : "请先选择一个对象或相机"; return ( -
+
+
diff --git a/src/editor/runtime/ActiveCameraPlaybackSync.tsx b/src/editor/runtime/ActiveCameraPlaybackSync.tsx index 041721c..de57c04 100644 --- a/src/editor/runtime/ActiveCameraPlaybackSync.tsx +++ b/src/editor/runtime/ActiveCameraPlaybackSync.tsx @@ -1,13 +1,13 @@ 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"; +import { applyActiveCameraViewAtTime } from "./playbackTransforms"; /** * 机位视图下:若当前激活相机有运镜轨道,每帧用插值结果覆盖渲染相机的 - * position / lookAt(target) / fov,呈现真正的运镜画面。 - * 挂在 内;非机位视图或相机无轨道时为空操作。 + * position / lookAt(target) / fov,呈现真正的运镜画面(逻辑在 playbackTransforms.ts, + * 与视频导出共用)。挂在 内;非机位视图或相机无轨道时为空操作。 */ export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: string | null }) { const camera = useThree((state) => state.camera); @@ -17,24 +17,15 @@ export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: s const editingObjectId = usePlaybackStore((state) => state.editingObjectId); useFrame(() => { - // 机位视角下覆盖渲染相机(播放 + scrub 均生效): - // - 导演视角不覆盖,避免抢走 OrbitControls 控制的导演相机 - // - 正在通过 gizmo / CameraPanel 编辑该相机时不覆盖 - if (viewMode !== "camera") return; - if (!activeCameraId) return; - if (editingObjectId === 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(); - } + // 机位视角下覆盖渲染相机(播放 + scrub 均生效),导演视角 / 编辑中不覆盖 + applyActiveCameraViewAtTime({ + camera: camera as ThreePerspectiveCamera, + cameraTracks, + activeCameraId, + playhead, + viewMode, + editingObjectId, + }); }); return null; diff --git a/src/editor/runtime/CharacterModel.tsx b/src/editor/runtime/CharacterModel.tsx index ffd78db..99a0bdf 100644 --- a/src/editor/runtime/CharacterModel.tsx +++ b/src/editor/runtime/CharacterModel.tsx @@ -9,6 +9,8 @@ interface CharacterModelProps { color?: string; onLabelAnchorYChange?: (anchorY: number) => void; rigState?: CharacterRigState; + motionTime?: number; + characterId?: string; } class CharacterModelBoundary extends Component< @@ -31,8 +33,8 @@ class CharacterModelBoundary extends Component< } } -export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState }: CharacterModelProps) { - const fallback = ; +export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState, motionTime, characterId }: CharacterModelProps) { + const fallback = ; if (rigState?.rigType !== "ue4-mannequin") { return fallback; @@ -45,6 +47,8 @@ export function CharacterModel({ bodyType, color, onLabelAnchorYChange, rigState color={color} onLabelAnchorYChange={onLabelAnchorYChange} rigState={rigState} + motionTime={motionTime} + characterId={characterId} /> ); diff --git a/src/editor/runtime/PrimitiveMannequin.tsx b/src/editor/runtime/PrimitiveMannequin.tsx index c7125c1..8dfaa15 100644 --- a/src/editor/runtime/PrimitiveMannequin.tsx +++ b/src/editor/runtime/PrimitiveMannequin.tsx @@ -6,8 +6,10 @@ interface PrimitiveMannequinProps { bodyType?: CharacterBodyType; color?: string; rigState?: CharacterRigState; + motionTime?: number; + characterId?: string; } -export function PrimitiveMannequin({ bodyType, color = "#4F8EF7", rigState }: PrimitiveMannequinProps) { - return ; +export function PrimitiveMannequin({ bodyType, color = "#4F8EF7", rigState, motionTime, characterId }: PrimitiveMannequinProps) { + return ; } diff --git a/src/editor/runtime/UE4MannequinModel.tsx b/src/editor/runtime/UE4MannequinModel.tsx index 5857a62..75e8122 100644 --- a/src/editor/runtime/UE4MannequinModel.tsx +++ b/src/editor/runtime/UE4MannequinModel.tsx @@ -16,6 +16,7 @@ import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.j import type { CharacterRigState } from "../schema/directorProject"; import { VIEWPORT_OBJECT_LABEL_VERTICAL_GAP } from "../schema/viewportLabels"; import type { CharacterBodyType } from "./mannequin/bodyTypes"; +import { applyMotionOverrides } from "./mannequin/motion"; import { UE4_MANNEQUIN_MODEL_URL, getUE4ModelScale, @@ -27,6 +28,10 @@ interface UE4MannequinModelProps { color?: string; onLabelAnchorYChange?: (anchorY: number) => void; rigState?: CharacterRigState; + /** 全局时间(秒)= playbackStore.playhead;驱动基础动作循环。 */ + motionTime?: number; + /** 稳定 id,用于群众相位偏移。 */ + characterId?: string; } interface LoadedGLTF { @@ -129,18 +134,27 @@ export function UE4MannequinModel({ color = "#F3F5F7", onLabelAnchorYChange, rigState, + motionTime, + characterId, }: UE4MannequinModelProps) { const gltf = useLoader(GLTFLoader, UE4_MANNEQUIN_MODEL_URL) as LoadedGLTF; const scene = useMemo(() => cloneSkeleton(gltf.scene) as Group, [gltf.scene]); const restPose = useMemo(() => captureUE4RestPose(scene), [scene]); const modelScale = getUE4ModelScale(bodyType); + const rigControls = rigState?.controls ?? {}; + // 基础动作(走/跑)按 motionTime 计算关节角覆盖后合并进 controls(度数域)。 + // 播放/scrub 时 motionTime 每帧变化 → effect 每帧重摆姿势,即动画本体。 + const controlsWithMotion = useMemo( + () => applyMotionOverrides(rigControls, { motion: rigState?.motion, time: motionTime ?? 0, characterId: characterId ?? "" }), + [rigControls, rigState?.motion, motionTime, characterId] + ); useLayoutEffect(() => { isolateAndTintUE4MannequinMaterials(scene, color); applyUE4RestPoseAndRig(scene, { bodyType, - controls: rigState?.controls ?? {}, + controls: controlsWithMotion, restPose, }); alignUE4MannequinToGround(scene); @@ -152,7 +166,7 @@ export function UE4MannequinModel({ if (Number.isFinite(labelAnchorY)) { onLabelAnchorYChange?.(Number(labelAnchorY.toFixed(4))); } - }, [bodyType, color, onLabelAnchorYChange, restPose, rigState?.controls, scene]); + }, [bodyType, color, controlsWithMotion, onLabelAnchorYChange, restPose, scene]); return ( diff --git a/src/editor/runtime/mannequin/ProceduralMannequin.tsx b/src/editor/runtime/mannequin/ProceduralMannequin.tsx index b857493..650b135 100644 --- a/src/editor/runtime/mannequin/ProceduralMannequin.tsx +++ b/src/editor/runtime/mannequin/ProceduralMannequin.tsx @@ -2,11 +2,16 @@ import type { CharacterRigState } from "../../schema/directorProject"; import { getBodyPreset, type CharacterBodyType } from "./bodyTypes"; import { degreesToRadians, getBodyTypePoseLimit, getRotationFromControls, getSingleAxisRotation } from "./mannequinPose"; import { Foot, Hand, Head, Joint, Segment, Torso } from "./mannequinParts"; +import { applyMotionOverrides } from "./motion"; interface ProceduralMannequinProps { bodyType?: CharacterBodyType; color?: string; rigState?: CharacterRigState; + /** 全局时间(秒),通常 = playbackStore.playhead。仅在 motion.kind !== "none" 时被使用。 */ + motionTime?: number; + /** 稳定 id,用于驱动 phase 偏移;缺省时所有个体相位一致。 */ + characterId?: string; } function clampDegrees(value: number, bodyType?: CharacterBodyType) { @@ -26,9 +31,13 @@ function getLimbRotation( ]; } -export function ProceduralMannequin({ bodyType, color = "#4F8EF7", rigState }: ProceduralMannequinProps) { +export function ProceduralMannequin({ bodyType, color = "#4F8EF7", rigState, motionTime, characterId }: ProceduralMannequinProps) { const preset = getBodyPreset(bodyType); - const controls = rigState?.controls ?? {}; + const controls = applyMotionOverrides(rigState?.controls ?? {}, { + motion: rigState?.motion, + time: motionTime ?? 0, + characterId: characterId ?? "", + }); const p = preset.proportions; const bodyRotation = getRotationFromControls(controls, "body", preset.bodyType); diff --git a/src/editor/runtime/mannequin/motion.test.ts b/src/editor/runtime/mannequin/motion.test.ts new file mode 100644 index 0000000..8845665 --- /dev/null +++ b/src/editor/runtime/mannequin/motion.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "vitest"; +import { + applyMotionOverrides, + computeMotionOverrides, + hashIdToPhase, + MOTION_DRIVEN_CONTROL_KEYS, + RUN_FREQUENCY_MULTIPLIER, +} from "./motion"; +import type { CharacterMotionState } from "../../schema/directorProject"; + +const WALK: CharacterMotionState = { kind: "walk", speed: 1, amplitude: 1, enabled: true }; +const RUN: CharacterMotionState = { kind: "run", speed: 1, amplitude: 1, enabled: true }; + +describe("computeMotionOverrides", () => { + it("returns empty override when motion is null/undefined", () => { + expect(computeMotionOverrides({ motion: null, time: 1, characterId: "x" })).toEqual({}); + expect(computeMotionOverrides({ motion: undefined, time: 1, characterId: "x" })).toEqual({}); + }); + + it("returns empty override when kind is none", () => { + expect(computeMotionOverrides({ motion: { ...WALK, kind: "none" }, time: 1, characterId: "x" })).toEqual({}); + }); + + it("returns empty override when enabled=false", () => { + expect(computeMotionOverrides({ motion: { ...WALK, enabled: false }, time: 1, characterId: "x" })).toEqual({}); + }); + + it("returns empty override for idle (MVP placeholder)", () => { + expect( + computeMotionOverrides({ motion: { ...WALK, kind: "idle" }, time: 1, characterId: "x" }) + ).toEqual({}); + }); + + /** 求使左肢相位 θL = target 的 time(speed=1,走路频率)。 */ + function timeForLeftPhase(target: number, characterId: string): number { + const phase = hashIdToPhase(characterId); + return (target - phase) / (Math.PI * 2); + } + + /** 求使跑步左肢相位 θL = target 的 time(speed=1;跑步基础频率 ×1.5)。 */ + function timeForRunLeftPhase(target: number, characterId: string): number { + const phase = hashIdToPhase(characterId); + return (target - phase) / (RUN_FREQUENCY_MULTIPLIER * Math.PI * 2); + } + + it("walk: 腿摆到最前(脚跟着地)时膝盖几乎伸直 —— 自然步态关键特征", () => { + const t = timeForLeftPhase(Math.PI / 2, "x"); + const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "x" }); + + // 左髋前摆峰值 25° + expect((o["leftHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(25); + // 右髋后摆 -12°(不对称摆幅) + expect((o["rightHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-12); + // 左膝着地瞬间伸直 + expect(o["leftKnee.bend"]).toBeCloseTo(0); + }); + + it("walk: 摆动中期膝峰 45°,对侧腿同时伸直", () => { + const t = timeForLeftPhase(0, "x"); + const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "x" }); + + expect((o["leftKnee.bend"] as number) * 180 / Math.PI).toBeCloseTo(45); + expect(o["rightKnee.bend"]).toBeCloseTo(0); + }); + + it("walk: 肘部微屈并随摆臂开合(前摆收紧、后摆打开)", () => { + const t = timeForLeftPhase(Math.PI / 2, "x"); + const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "x" }); + + // θL=π/2:左臂后摆(肘打开 6°),右臂前摆(肘收紧 18°);中心 12° ± 6° + expect((o["leftElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(6); + expect((o["rightElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(18); + expect(o).toHaveProperty("torso.yaw"); + }); + + it("walk: 臂与对侧腿同相(正值=前摆;左臂随右腿,不顺拐)", () => { + const t = timeForLeftPhase(Math.PI / 2, "x"); + const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "x" }); + + // 左腿前摆(+25)→ 左臂后摆(-16)、右臂前摆(+24),对侧摆动 + expect((o["leftShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-16); + expect((o["rightShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(24); + }); + + it("run: 前倾 -11°、肩对称摆 ±36°+微内旋 8°+贴身外展、屈肘明显", () => { + const t = timeForRunLeftPhase(0, "x"); + const o = computeMotionOverrides({ motion: RUN, time: t, characterId: "x" }); + + // 摆动中期(θarm 中点)肘角 55°(区间中点) + expect((o["leftElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(55); + // 肩部:对称摆幅 ±36°,中心 0(此刻两臂都过中点) + expect((o["leftShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(0); + // 上臂微内旋 8° + expect((o["leftShoulder.twist"] as number) * 180 / Math.PI).toBeCloseTo(8); + expect((o["rightShoulder.twist"] as number) * 180 / Math.PI).toBeCloseTo(8); + // 双臂贴身:微外展 8°(左右符号镜像) + expect((o["leftShoulder.spread"] as number) * 180 / Math.PI).toBeCloseTo(-8); + expect((o["rightShoulder.spread"] as number) * 180 / Math.PI).toBeCloseTo(8); + // 身体前倾为负值方向,中心 -14° ± 2° 起伏 + const bodyPitchDeg = (o["body.pitch"] as number) * 180 / Math.PI; + expect(bodyPitchDeg).toBeLessThan(-12); + expect(bodyPitchDeg).toBeGreaterThan(-16); + // 腕部微屈(握拳近似) + expect((o["leftHand.pitch"] as number) * 180 / Math.PI).toBeCloseTo(12); + // 摆动中期膝峰 90°(跑步小腿折叠) + expect((o["leftKnee.bend"] as number) * 180 / Math.PI).toBeCloseTo(90); + }); + + it("run: 肘部在 48°~62° 区间开合(前摆收紧、后摆稍开),肩前 +36 后 -36", () => { + // θL=π/2:左腿前摆 → 左臂后摆(肘 48),右臂前摆(肘收紧 62) + const t = timeForRunLeftPhase(Math.PI / 2, "x"); + const o = computeMotionOverrides({ motion: RUN, time: t, characterId: "x" }); + + expect((o["leftElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(48); + expect((o["rightElbow.bend"] as number) * 180 / Math.PI).toBeCloseTo(62); + // 肩摆(正=前摆):右臂随左腿对侧前摆到 +36,左臂后摆到 -36 —— 对侧,不顺拐 + expect((o["rightShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(36); + expect((o["leftShoulder.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-36); + // 腿摆幅加大:左髋前摆峰值 52°,右髋同时后摆 -26° + expect((o["leftHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(52); + expect((o["rightHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-26); + // 跑姿前摆腿保持屈膝:腿到最前时膝 55°(不是走路的伸直 0°) + expect((o["leftKnee.bend"] as number) * 180 / Math.PI).toBeCloseTo(55); + }); + + it("walk respects amplitude multiplier", () => { + const strong: CharacterMotionState = { ...WALK, amplitude: 1.5 }; + const mild = computeMotionOverrides({ motion: WALK, time: 0.25, characterId: "x" }); + const big = computeMotionOverrides({ motion: strong, time: 0.25, characterId: "x" }); + // amplitude 是纯乘子 + expect((big["leftHip.pitch"] as number) / (mild["leftHip.pitch"] as number)).toBeCloseTo(1.5); + }); + + it("walk respects speed multiplier", () => { + const fast: CharacterMotionState = { ...WALK, speed: 2 }; + const slow = computeMotionOverrides({ motion: WALK, time: 0.25, characterId: "x" }); + const quick = computeMotionOverrides({ motion: fast, time: 0.125, characterId: "x" }); + // speed=2, t=0.125 等价于 speed=1, t=0.25 + expect(quick["leftHip.pitch"] as number).toBeCloseTo(slow["leftHip.pitch"] as number); + }); + + it("walk covers expected joint keys", () => { + const overrides = computeMotionOverrides({ motion: WALK, time: 0.5, characterId: "char_42" }); + const keys = new Set(Object.keys(overrides)); + for (const expected of [ + "leftHip.pitch", + "rightHip.pitch", + "leftKnee.bend", + "rightKnee.bend", + "leftShoulder.pitch", + "rightShoulder.pitch", + "leftElbow.bend", + "rightElbow.bend", + "torso.yaw", + ]) { + expect(keys).toContain(expected); + } + // 所有覆盖的键必须在 MOTION_DRIVEN_CONTROL_KEYS 中 + for (const k of keys) { + expect(MOTION_DRIVEN_CONTROL_KEYS.has(k)).toBe(true); + } + }); + + it("left and right hips are 180° out of phase (asymmetric swing: forward 25° / backward 12°)", () => { + // θL=π/2:左髋前摆峰值 25°,右髋(θR=θL+π)同时到达后摆峰值 -12° + const t = timeForLeftPhase(Math.PI / 2, "char_42"); + const o = computeMotionOverrides({ motion: WALK, time: t, characterId: "char_42" }); + expect((o["leftHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(25); + expect((o["rightHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-12); + + // 半周期后左右互换(真正的 180° 反相验证) + const tHalf = timeForLeftPhase(Math.PI / 2 + Math.PI, "char_42"); + const oHalf = computeMotionOverrides({ motion: WALK, time: tHalf, characterId: "char_42" }); + expect((oHalf["leftHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(-12); + expect((oHalf["rightHip.pitch"] as number) * 180 / Math.PI).toBeCloseTo(25); + }); + + it("different character ids produce different phase offsets", () => { + const a = computeMotionOverrides({ motion: WALK, time: 0.3, characterId: "char_a" }); + const b = computeMotionOverrides({ motion: WALK, time: 0.3, characterId: "char_b" }); + // 群体里不同个体不应齐刷刷 + expect(a["leftHip.pitch"]).not.toBeCloseTo(b["leftHip.pitch"] as number); + }); + + it("returns empty override for non-finite time", () => { + expect(computeMotionOverrides({ motion: WALK, time: Number.NaN, characterId: "x" })).toEqual({}); + expect(computeMotionOverrides({ motion: WALK, time: Number.POSITIVE_INFINITY, characterId: "x" })).toEqual({}); + }); +}); + +describe("applyMotionOverrides", () => { + it("returns the same controls reference when nothing is overridden", () => { + const controls = { "head.yaw": 5 }; + expect( + applyMotionOverrides(controls, { motion: null, time: 1, characterId: "x" }) + ).toBe(controls); + expect( + applyMotionOverrides(controls, { motion: { ...WALK, kind: "none" }, time: 1, characterId: "x" }) + ).toBe(controls); + }); + + it("分层合并:动作角度 + controls 偏移(度数域)", () => { + const controls = { "head.yaw": 5, "leftHip.pitch": 3 }; + const merged = applyMotionOverrides(controls, { motion: WALK, time: 0.25, characterId: "x" }); + const expected = computeMotionOverrides({ motion: WALK, time: 0.25, characterId: "x" }); + + expect(merged).not.toBe(controls); + expect(merged["head.yaw"]).toBe(5); // 动作未驱动的关节原样保留 + // 动作驱动的关节 = 动作角度 + controls 偏移 + expect(merged["leftHip.pitch"]).toBeCloseTo(3 + ((expected["leftHip.pitch"] as number) * 180) / Math.PI); + }); + + it("偏移微调示例:髋 +10° 偏移叠加在动作循环之上", () => { + const base = computeMotionOverrides({ motion: WALK, time: 0.25, characterId: "x" }); + const offsetDeg = 10; + const merged = applyMotionOverrides( + { "leftHip.pitch": offsetDeg }, + { motion: WALK, time: 0.25, characterId: "x" } + ); + + expect(merged["leftHip.pitch"]).toBeCloseTo(offsetDeg + ((base["leftHip.pitch"] as number) * 180) / Math.PI); + }); +}); + +describe("hashIdToPhase", () => { + it("produces a value in [0, 2π)", () => { + for (const id of ["a", "char_1", "long-character-id-123", "中文-id-也-ok"]) { + const p = hashIdToPhase(id); + expect(p).toBeGreaterThanOrEqual(0); + expect(p).toBeLessThan(Math.PI * 2); + } + }); + + it("is deterministic", () => { + expect(hashIdToPhase("foo")).toBe(hashIdToPhase("foo")); + }); + + it("different ids yield different phases in general", () => { + const phases = new Set(["a", "b", "c", "d", "e", "f"].map(hashIdToPhase)); + expect(phases.size).toBeGreaterThan(4); + }); +}); \ No newline at end of file diff --git a/src/editor/runtime/mannequin/motion.ts b/src/editor/runtime/mannequin/motion.ts new file mode 100644 index 0000000..34f08f2 --- /dev/null +++ b/src/editor/runtime/mannequin/motion.ts @@ -0,0 +1,221 @@ +import type { CharacterMotionState, MotionKind } from "../../schema/directorProject"; + +/** + * 基础动作关节驱动 + * + * 接受 motion 配置 + 时间 t(秒)+ character id,返回一组「关节角覆盖」。 + * 返回的对象只包含被动作驱动的键;调用方应当把它放在 rigState.controls **之上**进行合并。 + * + * 设计要点: + * - 纯函数:可在测试里直接断言 t=0 / π/2 / π 时的输出 + * - phase 偏移:根据 characterId 哈希成 0 – 2π 的常数偏移,让群体里不同个体不齐刷刷 + * - 不直接依赖 store / playbackStore / R3F,方便单测 + */ + +export type MotionOverride = Partial>; + +export interface MotionParams { + motion: CharacterMotionState | null | undefined; + /** 全局时间(秒),通常 = playbackStore.playhead */ + time: number; + /** 用于产生 phase 偏移的稳定 id(一般是 directorObject.id) */ + characterId: string; +} + +const DEFAULT_MOTION: CharacterMotionState = { + kind: "none", + speed: 1, + amplitude: 1, + enabled: true, +}; + +function normalizeMotion(motion: CharacterMotionState | null | undefined): CharacterMotionState { + if (!motion) return DEFAULT_MOTION; + return { + kind: motion.kind ?? "none", + speed: typeof motion.speed === "number" ? motion.speed : 1, + amplitude: typeof motion.amplitude === "number" ? motion.amplitude : 1, + enabled: motion.enabled !== false, + }; +} + +/** 把任意 string hash 成 [0, 2π) 的浮点。FNV-1a 足够稳定。 */ +export function hashIdToPhase(id: string): number { + let hash = 2166136261; + for (let i = 0; i < id.length; i += 1) { + hash ^= id.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + // 映射到 [0, 2π) + return ((hash >>> 0) / 0xffffffff) * Math.PI * 2; +} + +function degreesToRadians(deg: number): number { + return (deg * Math.PI) / 180; +} + +/** 跑步相对走路的基础频率倍数:真人跑步步频更快(周期 ≈ 0.67s @speed=1,走路 1s)。 */ +export const RUN_FREQUENCY_MULTIPLIER = 1.5; + +/** 单腿/单臂的相位:左 0、右 π(与左反相)。 */ +function leftPhase(omegaT: number): number { + return omegaT; +} +function rightPhase(omegaT: number): number { + return omegaT + Math.PI; +} + +/** + * 腿/臂的相位语义(θ 为该肢相位): + * - 髋 hip = f(sin θ):θ=π/2 腿摆到最前(脚跟着地),θ=3π/2 最后(蹬离) + * - 膝 knee ∝ max(0, cos θ):cos>0 = 摆动期(腿从后往前扫),θ=0 为摆动中期膝峰; + * θ=π/2 着地时 cos=0 → 膝伸直 —— 这是自然步态与"正步走"的关键区别 + * - 摆幅不对称:前摆大、后摆小(真人步态如此) + */ +function swingHip(forwardDeg: number, backDeg: number, amp: number, theta: number): number { + const s = Math.sin(theta); + return (s >= 0 ? forwardDeg * s : backDeg * s) * amp; +} + +function swingKnee(peakDeg: number, amp: number, theta: number): number { + return peakDeg * amp * Math.max(0, Math.cos(theta)); +} + +/** 跑步膝部曲线(与走路不同:跑姿全程屈膝,不伸直锁死): + * - 基础 baseDeg:支撑期也保持微弯(吸收冲击) + * - 前摆加弯 frontDeg:腿摆到最前(θ=π/2)= base+front(脚掌着地屈膝) + * - 折叠 foldDeg:摆动中期(θ=0)小腿收起达折叠峰 */ +function runKnee(baseDeg: number, frontDeg: number, foldDeg: number, amp: number, theta: number): number { + const front = Math.max(0, Math.sin(theta)) * frontDeg; + const fold = Math.max(0, Math.cos(theta)) * foldDeg; + return (baseDeg + front + fold) * amp; +} + +function swingShoulder(forwardDeg: number, backDeg: number, amp: number, theta: number): number { + const s = Math.sin(theta); + return (s >= 0 ? forwardDeg * s : backDeg * s) * amp; +} + +/** 计算 idle / walk / run 的关节覆盖;none / 禁用 / 异常输入返回空对象。 */ +export function computeMotionOverrides({ motion, time, characterId }: MotionParams): MotionOverride { + const m = normalizeMotion(motion); + if (!m.enabled) return {}; + if (m.kind === "none") return {}; + if (!Number.isFinite(time)) return {}; + + const phase = hashIdToPhase(characterId); + const omegaT = time * m.speed * Math.PI * 2 + phase; + const L = leftPhase(omegaT); + const R = rightPhase(omegaT); + + if (m.kind === "idle") { + // 当前 MVP 只保留接口:闲置无明显关节驱动。 + // 后续阶段会接入轻微呼吸/眨眼。 + return {}; + } + + if (m.kind === "walk") { + const amp = m.amplitude; + return { + // 髋:前摆 25° / 后摆 12°(正常步幅),左右反相 + "leftHip.pitch": degreesToRadians(swingHip(25, 12, amp, L)), + "rightHip.pitch": degreesToRadians(swingHip(25, 12, amp, R)), + // 膝:摆动中期峰 45°,着地/支撑期伸直 + "leftKnee.bend": degreesToRadians(swingKnee(45, amp, L)), + "rightKnee.bend": degreesToRadians(swingKnee(45, amp, R)), + // 臂与对侧腿同相(肩 pitch 正值=前摆,与髋同号;左臂随右腿):前摆 24°、后摆 16° + "leftShoulder.pitch": degreesToRadians(swingShoulder(24, 16, amp, R)), + "rightShoulder.pitch": degreesToRadians(swingShoulder(24, 16, amp, L)), + // 肘微屈并随摆臂开合:中心 12° ± 6°(前摆收紧 18°、后摆打开 6°) + "leftElbow.bend": degreesToRadians((12 + 6 * Math.sin(R)) * amp), + "rightElbow.bend": degreesToRadians((12 + 6 * Math.sin(L)) * amp), + // 躯干与肩线反向微扭,让上半身有跟随感 + "torso.yaw": degreesToRadians(4 * amp * Math.sin(L)), + }; + } + + if (m.kind === "run") { + const amp = m.amplitude; + // 跑步步频更快:独立相位(基础频率 × 1.5),speed 仍作乘数 + const runL = leftPhase(time * m.speed * RUN_FREQUENCY_MULTIPLIER * Math.PI * 2 + phase); + const runR = rightPhase(runL); + return { + // 髋:跑步前摆更大 52° / 后摆 26° + "leftHip.pitch": degreesToRadians(swingHip(52, 26, amp, runL)), + "rightHip.pitch": degreesToRadians(swingHip(52, 26, amp, runR)), + // 膝:跑姿全程屈膝 —— 基础 30°(支撑吸震)、前摆至最前 55°(脚掌着地屈膝)、摆动中期折叠峰 90° + "leftKnee.bend": degreesToRadians(runKnee(30, 25, 60, amp, runL)), + "rightKnee.bend": degreesToRadians(runKnee(30, 25, 60, amp, runR)), + // 臂与对侧腿同相(肩 pitch 正值=前摆):对称摆幅 ±36°(参考校准姿态:左 -36 / 右 +36 交替) + "leftShoulder.pitch": degreesToRadians((36 * Math.sin(runR)) * amp), + "rightShoulder.pitch": degreesToRadians((36 * Math.sin(runL)) * amp), + // 上臂微内旋(前臂朝前)+ 微外展贴身,双臂不张开 + "leftShoulder.twist": degreesToRadians(8 * amp), + "rightShoulder.twist": degreesToRadians(8 * amp), + "leftShoulder.spread": degreesToRadians(-8 * amp), + "rightShoulder.spread": degreesToRadians(8 * amp), + // 肘部跑姿屈肘明显:中心 55° ± 7°(前摆收紧 62°、后摆打开 48°) + "leftElbow.bend": degreesToRadians((55 + 7 * Math.sin(runR)) * amp), + "rightElbow.bend": degreesToRadians((55 + 7 * Math.sin(runL)) * amp), + // 腕部微屈内收:rig 无手指骨骼,用腕部张力近似"握拳"姿态 + "leftHand.pitch": degreesToRadians(12 * amp), + "rightHand.pitch": degreesToRadians(12 * amp), + // 躯干反扭更明显 + "torso.yaw": degreesToRadians(7 * amp * Math.sin(runL)), + // 前倾 -14°(快跑水平)+ 步频两倍的上下起伏 + "body.pitch": degreesToRadians((-14 + 2 * Math.sin(runL * 2)) * amp), + }; + } + + return {}; +} + +/** 把动作叠加进 controls(度数域,分层模型)。 + * 动作是基础层(优先级最高);controls 里的值作为叠加偏移(微调层): + * 最终角度 = 动作角度 + controls[key]。动作未驱动的关节仍直接用 controls[key]。 + * 进动作时 controls 已被重置为站立({}),因此偏移从干净状态开始累积。 + * computeMotionOverrides 返回弧度;controls 全库统一用度数,这里做换算后相加。 + * 不做 clamp —— 两条渲染路径(ProceduralMannequin / UE4 rig)各自有关节限位逻辑。 + * 返回原对象引用当且仅当没有任何覆盖(便于跳过无谓重算)。 */ +export function applyMotionOverrides( + controls: Record, + params: MotionParams +): Record { + const overrides = computeMotionOverrides(params); + if (Object.keys(overrides).length === 0) return controls; + + const next: Record = { ...controls }; + for (const [key, radians] of Object.entries(overrides)) { + if (typeof radians !== "number" || !Number.isFinite(radians)) continue; + next[key] = (next[key] ?? 0) + (radians * 180) / Math.PI; + } + return next; +} + +/** 给 UI 用的枚举列表(与 MotionKind 一一对应)。 */ +export const MOTION_OPTIONS: { value: MotionKind; label: string }[] = [ + { value: "none", label: "无" }, + { value: "idle", label: "闲置" }, + { value: "walk", label: "走路" }, + { value: "run", label: "跑步" }, +]; + +/** 走路/跑步驱动的关节键集合(文档性质:这些键的动作值会被 controls 偏移叠加)。 */ +export const MOTION_DRIVEN_CONTROL_KEYS = new Set([ + "leftHip.pitch", + "rightHip.pitch", + "leftKnee.bend", + "rightKnee.bend", + "leftShoulder.pitch", + "rightShoulder.pitch", + "leftShoulder.twist", + "rightShoulder.twist", + "leftShoulder.spread", + "rightShoulder.spread", + "leftElbow.bend", + "rightElbow.bend", + "leftHand.pitch", + "rightHand.pitch", + "torso.yaw", + "body.pitch", +]); \ No newline at end of file diff --git a/src/editor/runtime/playbackTransforms.test.ts b/src/editor/runtime/playbackTransforms.test.ts new file mode 100644 index 0000000..67fdc15 --- /dev/null +++ b/src/editor/runtime/playbackTransforms.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { Object3D, PerspectiveCamera, Vector3 } from "three"; +import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline"; +import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "./playbackTransforms"; +import { computeExportSize } from "../io/videoExport"; +import { getViewportCameraQuaternion } from "../canvas/SceneRoot"; + +function makeObjectTrack(): TimelineTrack { + return { + objectId: "obj-1", + keyframes: [ + { + id: "kf-1", + time: 0, + value: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }, + }, + { + id: "kf-2", + time: 2, + value: { position: [4, 6, 8], rotation: [0, 0, 0], scale: [1, 1, 1] }, + }, + ], + }; +} + +function makeCameraTrack(): CameraTimelineTrack { + return { + cameraId: "cam-1", + keyframes: [ + { id: "ckf-1", time: 0, value: { position: [0, 1, 0], target: [0, 0, 0], fov: 50 } }, + { id: "ckf-2", time: 2, value: { position: [2, 1, 0], target: [0, 0, 0], fov: 60 } }, + ], + }; +} + +it("applies interpolated object transform at the playhead", () => { + const scene = new Object3D(); + const child = new Object3D(); + child.userData.directorObjectId = "obj-1"; + scene.add(child); + + applyTimelineTransformsAtTime(scene, [makeObjectTrack()], [], 1, null); + + expect(child.position.toArray()).toEqual([2, 3, 4]); // 中点插值 +}); + +it("keeps editing object untouched (gizmo is dragging it)", () => { + const scene = new Object3D(); + const child = new Object3D(); + child.position.set(9, 9, 9); + child.userData.directorObjectId = "obj-1"; + scene.add(child); + + applyTimelineTransformsAtTime(scene, [makeObjectTrack()], [], 1, "obj-1"); + + expect(child.position.toArray()).toEqual([9, 9, 9]); +}); + +it("applies camera wireframe position and orientation from the camera track", () => { + const scene = new Object3D(); + const wire = new Object3D(); + wire.userData.directorCameraId = "cam-1"; + scene.add(wire); + + applyTimelineTransformsAtTime(scene, [], [makeCameraTrack()], 2, null); + + expect(wire.position.toArray()).toEqual([2, 1, 0]); + // 朝向使用 SceneRoot 的 getViewportCameraQuaternion 约定(与预览线框一致) + const expectedQuaternion = getViewportCameraQuaternion([2, 1, 0], [0, 0, 0]); + expect(wire.quaternion.equals(expectedQuaternion)).toBe(true); +}); + +it("overrides the render camera in camera view mode", () => { + const camera = new PerspectiveCamera(50, 16 / 9); + camera.position.set(100, 100, 100); + + const applied = applyActiveCameraViewAtTime({ + camera, + cameraTracks: [makeCameraTrack()], + activeCameraId: "cam-1", + playhead: 2, + viewMode: "camera", + editingObjectId: null, + }); + + expect(applied).toBe(true); + expect(camera.position.toArray()).toEqual([2, 1, 0]); + expect(camera.fov).toBe(60); +}); + +it("does not override the render camera in director view mode", () => { + const camera = new PerspectiveCamera(50, 16 / 9); + camera.position.set(100, 100, 100); + + const applied = applyActiveCameraViewAtTime({ + camera, + cameraTracks: [makeCameraTrack()], + activeCameraId: "cam-1", + playhead: 2, + viewMode: "director", + editingObjectId: null, + }); + + expect(applied).toBe(false); + expect(camera.position.x).toBe(100); +}); + +describe("computeExportSize", () => { + it("uses 16:9 short-edge-720 for a 16:9 ratio", () => { + expect(computeExportSize("16:9")).toEqual({ width: 1280, height: 720 }); + }); + + it("uses portrait 720x1280 for 9:16", () => { + expect(computeExportSize("9:16")).toEqual({ width: 720, height: 1280 }); + }); + + it("falls back to a fixed 16:9 when ratio is auto — never the squashed viewport aspect", () => { + // 时间轴拖高压扁视口(如 1200x400)时,auto 也固定导出 16:9,画面不会跟着变扁 + expect(computeExportSize("auto")).toEqual({ width: 1280, height: 720 }); + }); + + it("keeps even dimensions for the encoder", () => { + const { width, height } = computeExportSize("4:3"); + expect(width % 2).toBe(0); + expect(height % 2).toBe(0); + }); +}); diff --git a/src/editor/runtime/playbackTransforms.ts b/src/editor/runtime/playbackTransforms.ts new file mode 100644 index 0000000..4268a3e --- /dev/null +++ b/src/editor/runtime/playbackTransforms.ts @@ -0,0 +1,93 @@ +import type { Object3D, PerspectiveCamera } from "three"; +import { evaluateCameraTrackAtTime, evaluateTrackAtTime } from "../schema/timeline"; +import type { CameraTimelineTrack, TimelineTrack } from "../schema/timeline"; +import { getViewportCameraQuaternion } from "../canvas/SceneRoot"; + +/** + * 按 playhead 把物体 / 相机线框的关键帧插值结果直接写到场景中的 Three.js 对象。 + * SceneRoot 的 group 通过 userData 标记身份: + * - directorObjectId → 物体(覆盖 position/rotation/scale) + * - directorCameraId → 导演视图相机线框(覆盖 position + 朝向 target) + * 正在被 gizmo 拖拽的对象(editingObjectId)会被跳过,避免覆盖编辑中的值。 + * 预览(PlaybackTransformSync 的 useFrame)与视频导出(逐帧离线渲染)共用此逻辑, + * 保证「所见即所得」。 + */ +export function applyTimelineTransformsAtTime( + scene: Object3D, + tracks: TimelineTrack[], + cameraTracks: CameraTimelineTrack[], + playhead: number, + editingObjectId: string | null +) { + if (tracks.length === 0 && cameraTracks.length === 0) return; + + const trackMap = new Map(tracks.map((track) => [track.objectId, track])); + const cameraTrackMap = new Map(cameraTracks.map((track) => [track.cameraId, track])); + + scene.traverse((object) => { + const objectId = object.userData?.directorObjectId; + if (objectId && objectId !== editingObjectId) { + const track = trackMap.get(objectId); + if (track && track.keyframes.length > 0) { + const value = evaluateTrackAtTime(track, playhead); + if (value) { + object.position.set(value.position[0], value.position[1], value.position[2]); + object.rotation.set(value.rotation[0], value.rotation[1], value.rotation[2]); + object.scale.set(value.scale[0], value.scale[1], value.scale[2]); + } + } + } + + const cameraId = object.userData?.directorCameraId; + if (cameraId && cameraId !== editingObjectId) { + const track = cameraTrackMap.get(cameraId); + if (track && track.keyframes.length > 0) { + const value = evaluateCameraTrackAtTime(track, playhead); + if (value) { + object.position.set(value.position[0], value.position[1], value.position[2]); + object.quaternion.copy(getViewportCameraQuaternion(value.position, value.target)); + } + } + } + }); +} + +/** + * 机位视图下:若当前激活相机有运镜轨道,用插值结果覆盖渲染相机的 + * position / lookAt(target) / fov,呈现真正的运镜画面。 + * 返回是否覆盖了相机(导演视角或无轨道时不动)。 + * 预览(ActiveCameraPlaybackSync 的 useFrame)与视频导出共用此逻辑。 + */ +export function applyActiveCameraViewAtTime({ + camera, + cameraTracks, + activeCameraId, + playhead, + viewMode, + editingObjectId, +}: { + camera: PerspectiveCamera; + cameraTracks: CameraTimelineTrack[]; + activeCameraId: string | null; + playhead: number; + viewMode: "director" | "camera"; + editingObjectId: string | null; +}): boolean { + // 导演视角不覆盖,避免抢走 OrbitControls 控制的导演相机; + // 正在通过 gizmo / CameraPanel 编辑该相机时也不覆盖。 + if (viewMode !== "camera") return false; + if (!activeCameraId) return false; + if (editingObjectId === activeCameraId) return false; + const track = cameraTracks.find((item) => item.cameraId === activeCameraId); + if (!track || track.keyframes.length === 0) return false; + const value = evaluateCameraTrackAtTime(track, playhead); + if (!value) return false; + + camera.position.set(value.position[0], value.position[1], value.position[2]); + camera.lookAt(value.target[0], value.target[1], value.target[2]); + if (Math.abs(camera.fov - value.fov) > 0.001) { + camera.fov = value.fov; + camera.updateProjectionMatrix(); + } + return true; +} diff --git a/src/editor/schema/directorProject.ts b/src/editor/schema/directorProject.ts index dc33653..aec08bd 100644 --- a/src/editor/schema/directorProject.ts +++ b/src/editor/schema/directorProject.ts @@ -46,10 +46,26 @@ export interface SceneSettings { groundHeight: number; } +/** 基础动作类型:none=不应用循环,idle/walk/run 由 ProceduralMannequin 按 time 驱动。 */ +export type MotionKind = "none" | "idle" | "walk" | "run"; + +/** 单个角色的「动作」配置。当前 MVP 只接通 walk,其它类型字段保留以便后续扩。 */ +export interface CharacterMotionState { + kind: MotionKind; + /** 0.5 – 2.5,默认 1.0;与 playbackStore.time 相乘决定 ω。 */ + speed: number; + /** 0.5 – 1.5,默认 1.0;整体幅度乘数。 */ + amplitude: number; + /** 是否启用;false 时即使 kind ≠ none 也不驱动关节(方便临时冻结对比 pose)。 */ + enabled: boolean; +} + export interface CharacterRigState { rigType: CharacterRigType; posePresetId: string | null; controls: Record; + /** 缺省视为 { kind: "none", speed: 1, amplitude: 1, enabled: true };旧工程无需迁移。 */ + motion?: CharacterMotionState; } export interface DirectorAssetRef { diff --git a/src/editor/store/directorStore.motion.test.ts b/src/editor/store/directorStore.motion.test.ts new file mode 100644 index 0000000..88d8ae4 --- /dev/null +++ b/src/editor/store/directorStore.motion.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createInitialDirectorState, useDirectorStore } from "./directorStore"; + +function getCharacter(id: string) { + return useDirectorStore.getState().project.objects.find((item) => item.id === id); +} + +beforeEach(() => { + useDirectorStore.setState({ + ...useDirectorStore.getState(), + ...createInitialDirectorState(), + }); +}); + +describe("姿势与动作互斥", () => { + it("从「无」切到走路:姿势重置为站立基础态", () => { + // 先摆一个非站立姿势 + useDirectorStore.getState().applyPosePreset("char_default_a", "sit"); + expect(getCharacter("char_default_a")?.characterRig?.posePresetId).toBe("sit"); + + useDirectorStore.getState().setMotion("char_default_a", "walk"); + + const rig = getCharacter("char_default_a")?.characterRig; + expect(rig?.posePresetId).toBe("stand"); + expect(rig?.controls).toEqual({}); + expect(rig?.motion?.kind).toBe("walk"); + }); + + it("应用姿势预设:动作自动关闭,但保留速度/幅度调参", () => { + useDirectorStore.getState().setMotion("char_default_a", "walk"); + useDirectorStore.getState().setMotionSpeed("char_default_a", 1.8); + useDirectorStore.getState().setMotionAmplitude("char_default_a", 1.3); + + useDirectorStore.getState().applyPosePreset("char_default_a", "t-pose"); + + const rig = getCharacter("char_default_a")?.characterRig; + expect(rig?.motion?.kind).toBe("none"); + expect(rig?.motion?.speed).toBe(1.8); + expect(rig?.motion?.amplitude).toBe(1.3); + expect(rig?.posePresetId).toBe("t-pose"); + }); + + it("动作之间切换(walk→run):姿势数据整体重置,微调不残留", () => { + useDirectorStore.getState().setMotion("char_default_a", "walk"); + useDirectorStore.getState().updatePoseControl("char_default_a", "head.yaw", 30); + + useDirectorStore.getState().setMotion("char_default_a", "run"); + + const rig = getCharacter("char_default_a")?.characterRig; + expect(rig?.motion?.kind).toBe("run"); + expect(rig?.posePresetId).toBe("stand"); + expect(rig?.controls).toEqual({}); + }); + + it("切回「无」:姿势同样重置为站立", () => { + useDirectorStore.getState().setMotion("char_default_a", "walk"); + useDirectorStore.getState().updatePoseControl("char_default_a", "head.yaw", 15); + + useDirectorStore.getState().setMotion("char_default_a", "none"); + + const rig = getCharacter("char_default_a")?.characterRig; + expect(rig?.motion?.kind).toBe("none"); + expect(rig?.posePresetId).toBe("stand"); + expect(rig?.controls).toEqual({}); + }); + + it("重复选择同一动作:不触发重置", () => { + useDirectorStore.getState().setMotion("char_default_a", "walk"); + useDirectorStore.getState().updatePoseControl("char_default_a", "head.yaw", 20); + + useDirectorStore.getState().setMotion("char_default_a", "walk"); + + const rig = getCharacter("char_default_a")?.characterRig; + expect(rig?.controls["head.yaw"]).toBe(20); + }); + + it("群众:整组切动作重置为站立;整组摆姿势关动作", () => { + useDirectorStore.getState().addCrowdCharacters({ rows: 1, columns: 2, spacing: 1 }); + useDirectorStore.getState().selectCrowd("crowd_1"); + useDirectorStore.getState().applyCrowdPosePreset("crowd_1", "sit"); + + useDirectorStore.getState().setCrowdMotion("crowd_1", "walk"); + + const members = useDirectorStore + .getState() + .project.objects.filter((item) => item.kind === "character" && item.crowdId === "crowd_1"); + expect(members).toHaveLength(2); + for (const member of members) { + expect(member.characterRig?.posePresetId).toBe("stand"); + expect(member.characterRig?.motion?.kind).toBe("walk"); + } + + useDirectorStore.getState().applyCrowdPosePreset("crowd_1", "t-pose"); + const after = useDirectorStore + .getState() + .project.objects.filter((item) => item.kind === "character" && item.crowdId === "crowd_1"); + for (const member of after) { + expect(member.characterRig?.motion?.kind).toBe("none"); + expect(member.characterRig?.posePresetId).toBe("t-pose"); + } + }); +}); \ No newline at end of file diff --git a/src/editor/store/directorStore.test.ts b/src/editor/store/directorStore.test.ts index 3d8af57..017f1f0 100644 --- a/src/editor/store/directorStore.test.ts +++ b/src/editor/store/directorStore.test.ts @@ -271,6 +271,40 @@ it("deletes the selected list object and linked camera data", () => { expect(state.project.activeCameraId).toBe("cam_1"); }); +it("deletes the object's timeline track along with the object", () => { + useDirectorStore.setState(createInitialDirectorState()); + const store = useDirectorStore.getState(); + store.addKeyframe("char_default_a", 1, { + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }); + expect(useDirectorStore.getState().project.timeline.tracks).toHaveLength(1); + + useDirectorStore.getState().selectObject("char_default_a"); + useDirectorStore.getState().deleteSelectedObject(); + + expect(useDirectorStore.getState().project.timeline.tracks).toHaveLength(0); +}); + +it("deletes the linked camera track when a camera object is removed", () => { + useDirectorStore.setState(createInitialDirectorState()); + const cameraObject = useDirectorStore + .getState() + .project.objects.find((item) => item.kind === "camera" && item.linkedCameraId === "cam_1")!; + useDirectorStore.getState().addCameraKeyframe("cam_1", 1, { + position: [1, 1, 1], + target: [0, 0, 0], + fov: 50, + }); + expect(useDirectorStore.getState().project.timeline.cameraTracks).toHaveLength(1); + + useDirectorStore.getState().selectObject(cameraObject.id); + useDirectorStore.getState().deleteSelectedObject(); + + expect(useDirectorStore.getState().project.timeline.cameraTracks).toHaveLength(0); +}); + it("supports multi-selecting objects and deleting the selected set", () => { useDirectorStore.setState(createInitialDirectorState()); useDirectorStore.getState().addPresetCharacter("female"); diff --git a/src/editor/store/directorStore.ts b/src/editor/store/directorStore.ts index a160c6d..be2f853 100644 --- a/src/editor/store/directorStore.ts +++ b/src/editor/store/directorStore.ts @@ -12,6 +12,7 @@ import type { DirectorProject, DirectorTransform, GeometryPrimitiveType, + MotionKind, PanoramaProjectionMode, SceneSettings, ViewMode, @@ -137,6 +138,15 @@ export interface DirectorActions { applyCrowdPosePreset: (crowdId: string, presetId: PosePresetId) => void; updatePoseControl: (id: string, key: string, value: number) => void; updateCrowdPoseControl: (crowdId: string, key: string, value: number) => void; + // 基础动作(走路 / 跑步) + setMotion: (id: string, kind: MotionKind) => void; + setCrowdMotion: (crowdId: string, kind: MotionKind) => void; + setMotionSpeed: (id: string, speed: number) => void; + setCrowdMotionSpeed: (crowdId: string, speed: number) => void; + setMotionAmplitude: (id: string, amplitude: number) => void; + setCrowdMotionAmplitude: (crowdId: string, amplitude: number) => void; + setMotionEnabled: (id: string, enabled: boolean) => void; + setCrowdMotionEnabled: (crowdId: string, enabled: boolean) => void; setActiveCamera: (cameraId: string) => void; addCameraCaptures: (cameraId: string | null | undefined, dataUrls: string[]) => void; updateCamera: ( @@ -573,6 +583,55 @@ function updateObjectById( return objects.map((item) => (item.id === id ? updater(item) : item)); } +/** 把 motion 字段的 patch 应用到单个 character;若没有 rig 则保持不变。 */ +function applyMotionPatchToCharacter( + character: DirectorObject, + patch: Partial<{ kind: MotionKind; speed: number; amplitude: number; enabled: boolean }> +): DirectorObject { + if (!character.characterRig) return character; + const current = character.characterRig.motion ?? { + kind: "none" as MotionKind, + speed: 1, + amplitude: 1, + enabled: true, + }; + return { + ...character, + characterRig: { + ...character.characterRig, + motion: { + kind: patch.kind ?? current.kind, + speed: typeof patch.speed === "number" ? patch.speed : current.speed, + amplitude: typeof patch.amplitude === "number" ? patch.amplitude : current.amplitude, + enabled: typeof patch.enabled === "boolean" ? patch.enabled : current.enabled, + }, + }, + }; +} + +/** 姿势与动作互斥(选择层面): + * - 任何动作切换(无→walk、walk→run、run→无…)都把姿势数据整体重置为 + * 动作默认状态(站立基础态),避免上一动作/姿势的偏移残留到下一动作 + * - 重复选择同一动作不触发重置 */ +function applyMotionSelectionToCharacter(character: DirectorObject, kind: MotionKind): DirectorObject { + if (!character.characterRig) return character; + const current = character.characterRig.motion ?? { + kind: "none" as MotionKind, + speed: 1, + amplitude: 1, + enabled: true, + }; + const nextRig: NonNullable = { + ...character.characterRig, + motion: { ...current, kind }, + }; + if (kind !== current.kind) { + nextRig.posePresetId = "stand"; + nextRig.controls = {}; + } + return { ...character, characterRig: nextRig }; +} + function getNextCharacterColor(objects: DirectorObject[]) { const usedColors = new Set(objects.filter((item) => item.kind === "character").map((item) => item.color)); const unusedColor = CHARACTER_COLOR_PALETTE.find((color) => !usedColors.has(color)); @@ -1838,6 +1897,17 @@ export const useDirectorStore = create((set, get) => { ) ); + // 同步清理被删对象的关键帧轨道:物体轨道按 objectId、相机轨道按 linkedCameraId + const nextTracks = state.project.timeline.tracks.filter( + (track) => !selectedObjectIdSet.has(track.objectId) + ); + const nextCameraTracks = state.project.timeline.cameraTracks.filter( + (track) => !linkedCameraIds.has(track.cameraId) + ); + const timelineUnchanged = + nextTracks.length === state.project.timeline.tracks.length && + nextCameraTracks.length === state.project.timeline.cameraTracks.length; + return { ...state, selectedObjectId: null, @@ -1850,6 +1920,13 @@ export const useDirectorStore = create((set, get) => { objects: nextObjects, cameras: nextFocusedCameras, activeCameraId: nextActiveCameraId, + timeline: timelineUnchanged + ? state.project.timeline + : { + ...state.project.timeline, + tracks: nextTracks, + cameraTracks: nextCameraTracks, + }, }, }; }), @@ -1885,11 +1962,15 @@ export const useDirectorStore = create((set, get) => { ...state.project, objects: updateObjectById(state.project.objects, id, (item) => ({ ...item, + // 姿势与动作互斥:摆造型自动关掉动作循环(保留速度/幅度等调参) characterRig: item.characterRig ? { ...item.characterRig, posePresetId: presetId, controls: preset ? { ...preset.controls } : item.characterRig.controls, + motion: item.characterRig.motion + ? { ...item.characterRig.motion, kind: "none" } + : item.characterRig.motion, } : item.characterRig, })), @@ -1908,11 +1989,15 @@ export const useDirectorStore = create((set, get) => { item.kind === "character" && item.crowdId === crowdId ? { ...item, + // 姿势与动作互斥:摆造型自动关掉动作循环(保留速度/幅度等调参) characterRig: item.characterRig ? { ...item.characterRig, posePresetId: presetId, controls: preset ? { ...preset.controls } : item.characterRig.controls, + motion: item.characterRig.motion + ? { ...item.characterRig.motion, kind: "none" } + : item.characterRig.motion, } : item.characterRig, } @@ -1963,6 +2048,86 @@ export const useDirectorStore = create((set, get) => { ), }, })), + setMotion: (id, kind) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: updateObjectById(state.project.objects, id, (item) => + item.kind === "character" ? applyMotionSelectionToCharacter(item, kind) : item + ), + }, + })), + setCrowdMotion: (crowdId, kind) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: state.project.objects.map((item) => + item.kind === "character" && item.crowdId === crowdId ? applyMotionSelectionToCharacter(item, kind) : item + ), + }, + })), + setMotionSpeed: (id, speed) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: updateObjectById(state.project.objects, id, (item) => + item.kind === "character" ? applyMotionPatchToCharacter(item, { speed }) : item + ), + }, + })), + setCrowdMotionSpeed: (crowdId, speed) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: state.project.objects.map((item) => + item.kind === "character" && item.crowdId === crowdId ? applyMotionPatchToCharacter(item, { speed }) : item + ), + }, + })), + setMotionAmplitude: (id, amplitude) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: updateObjectById(state.project.objects, id, (item) => + item.kind === "character" ? applyMotionPatchToCharacter(item, { amplitude }) : item + ), + }, + })), + setCrowdMotionAmplitude: (crowdId, amplitude) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: state.project.objects.map((item) => + item.kind === "character" && item.crowdId === crowdId ? applyMotionPatchToCharacter(item, { amplitude }) : item + ), + }, + })), + setMotionEnabled: (id, enabled) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: updateObjectById(state.project.objects, id, (item) => + item.kind === "character" ? applyMotionPatchToCharacter(item, { enabled }) : item + ), + }, + })), + setCrowdMotionEnabled: (crowdId, enabled) => + commitMutation((state) => ({ + ...state, + project: { + ...state.project, + objects: state.project.objects.map((item) => + item.kind === "character" && item.crowdId === crowdId ? applyMotionPatchToCharacter(item, { enabled }) : item + ), + }, + })), setActiveCamera: (cameraId) => commitUiMutation((state) => { const selectedObjectId = diff --git a/src/styles/index.css b/src/styles/index.css index 322b22e..00d8344 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -619,7 +619,8 @@ textarea:focus-visible { .right-sidebar .right-inspector-tabs { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + /* auto-fit:2 个 tab(属性/拍摄记录)排 2 列,3 个 tab(属性/姿势/动作)排 3 列,同行不换行 */ + grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); align-self: stretch; gap: 0; width: calc(100% + 40px); @@ -2555,6 +2556,54 @@ textarea:focus-visible { max-width: 100%; } +/* 动作类型:单选按钮组(快速点选,不用下拉) */ +.motion-kind-field { + display: grid; + gap: 6px; + width: var(--right-sidebar-content-width); + max-width: 100%; + border: none; + margin: 0; + padding: 0; +} +.motion-kind-field legend { + padding: 0; + font-size: 13px; + color: rgb(var(--text-muted-rgb)); +} +.motion-kind-options { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; +} +.motion-kind-option { + display: grid; + place-items: center; + height: 32px; + border: 1px solid rgb(var(--border-rgb)); + border-radius: 6px; + font-size: 12px; + color: rgb(var(--text-muted-rgb)); + cursor: pointer; + user-select: none; +} +.motion-kind-option input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} +.motion-kind-option:has(input:focus-visible) { + outline: 2px solid rgb(var(--accent-rgb)); + outline-offset: 1px; +} +.motion-kind-option.is-active { + border-color: rgb(var(--accent-rgb) / 0.32); + color: rgb(var(--accent-rgb)); + background: rgb(var(--accent-rgb) / 0.08); +} + .preset-grid { display: grid; grid-template-columns: repeat(4, 57px); @@ -2607,6 +2656,45 @@ textarea:focus-visible { justify-items: start; } +/* 姿势调节两级结构:一级 = 身体区域(可折叠 details/summary),二级 = .pose-group 部位滑杆 */ +.pose-sections { + display: grid; + gap: 6px; + width: var(--right-sidebar-content-width); +} +.pose-section > summary { + position: relative; + padding: 5px 8px 5px 22px; + border-radius: 6px; + font-weight: 600; + font-size: 13px; + cursor: pointer; + user-select: none; + list-style: none; +} +.pose-section > summary::-webkit-details-marker { + display: none; +} +.pose-section > summary::before { + content: ""; + position: absolute; + left: 9px; + top: 50%; + width: 0; + height: 0; + border-left: 5px solid rgb(var(--text-muted-rgb)); + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + transform: translateY(-50%); + transition: transform 0.12s; +} +.pose-section[open] > summary::before { + transform: translateY(-50%) rotate(90deg); +} +.pose-section > summary:hover { + background: rgb(var(--surface-hover-rgb)); +} + .pose-group { display: grid; gap: 10px; @@ -3269,7 +3357,40 @@ textarea:focus-visible { gap: 8px; padding: 8px 12px; flex-shrink: 0; - height: 220px; + height: 300px; /* 组件会用 inline style 覆盖(拖拽/持久化的高度),这里只是默认值 */ + max-height: 420px; /* 上限固定:关键帧轨道再多也在面板内滚动(.timeline-tracks overflow-y:auto) */ +} +.timeline-resizer { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + height: 10px; + margin: -8px -12px 0; /* 抵消面板 padding,让手柄贴顶并贯通整个宽度 */ + cursor: row-resize; + touch-action: none; +} +.timeline-resizer::before { + content: ""; + width: 56px; + height: 4px; + border-radius: 2px; + background: rgb(var(--text-dim-rgb)); + opacity: 0.5; + transition: background 0.12s, opacity 0.12s; +} +.timeline-resizer:hover::before, +.timeline-resizer.is-active::before { + background: rgb(var(--accent-rgb)); + opacity: 1; +} +body.is-resizing-timeline { + cursor: row-resize; + user-select: none; +} +/* 视频导出期间:drawing buffer 切到固定导出分辨率,CSS 铺满会拉伸变形 → contain 保持比例 */ +.director-canvas canvas.is-video-exporting { + object-fit: contain; } /* 侧栏展开时,时间轴左右避让浮动的左/右侧栏(侧栏 position:absolute 浮在画布上) */ @@ -3306,12 +3427,15 @@ textarea:focus-visible { gap: 4px; flex: 1; min-height: 0; + padding-top: 6px; /* 给关键帧删除按钮的顶部溢出留空间,避免第一行被裁 */ + overflow-x: hidden; /* 0%/100% 处的关键帧方块/删除按钮会微微超出轨道边缘,不产生横向滚动条 */ overflow-y: auto; } .timeline-track { display: flex; align-items: center; gap: 8px; + padding: 0 14px; /* 左右留白:贴边关键帧完整可见可点,不再溢出产生横向滚动条 */ } .timeline-track-label { width: 110px; diff --git a/src/styles/index.css.test.ts b/src/styles/index.css.test.ts index 458338d..3dc7947 100644 --- a/src/styles/index.css.test.ts +++ b/src/styles/index.css.test.ts @@ -73,7 +73,7 @@ it("matches the provided right inspector layout dimensions and field styling", ( expect(css).toMatch(/\.right-inspector\s*\{[\s\S]*?padding:\s*20px;[\s\S]*?gap:\s*0;/); expect(css).toMatch(/\.right-sidebar\s*\.right-inspector\s*\{[\s\S]*?gap:\s*0;[\s\S]*?background:\s*rgb\(var\(--panel-rgb\)\);/); expect(css).toMatch(/\.right-inspector-title\s*\{[\s\S]*?font-size:\s*16px;[\s\S]*?line-height:\s*22px;/); - expect(css).toMatch(/\.right-inspector-tabs\s*\{[\s\S]*?display:\s*grid;[\s\S]*?grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\);[\s\S]*?gap:\s*0;[\s\S]*?height:\s*40px;[\s\S]*?margin:\s*10px -20px 0;[\s\S]*?border-bottom:\s*1px solid rgb\(var\(--border-rgb\) \/ 0\.24\);/); + expect(css).toMatch(/\.right-inspector-tabs\s*\{[\s\S]*?display:\s*grid;[\s\S]*?grid-template-columns:\s*repeat\(auto-fit,\s*minmax\(64px,\s*1fr\)\);[\s\S]*?gap:\s*0;[\s\S]*?height:\s*40px;[\s\S]*?margin:\s*10px -20px 0;[\s\S]*?border-bottom:\s*1px solid rgb\(var\(--border-rgb\) \/ 0\.24\);/); expect(css).toMatch(/\.right-sidebar\s*\.right-inspector-tabs\s*button\s*\{[\s\S]*?display:\s*grid;[\s\S]*?place-items:\s*center;[\s\S]*?width:\s*100%;[\s\S]*?min-width:\s*24px;[\s\S]*?height:\s*40px;[\s\S]*?font-size:\s*12px;[\s\S]*?line-height:\s*17px;[\s\S]*?white-space:\s*nowrap;/); expect(css).toMatch(/\.right-sidebar\s*\.right-inspector-tabs\s*button\[aria-pressed="true"\]::after\s*\{[\s\S]*?width:\s*28px;[\s\S]*?height:\s*3px;[\s\S]*?border-radius:\s*0;/); expect(css).toMatch(/\.right-inspector-content\s*\{[\s\S]*?gap:\s*20px;[\s\S]*?width:\s*var\(--right-sidebar-content-width\);[\s\S]*?padding-bottom:\s*20px;[\s\S]*?margin-top:\s*25px;/);