import type { Timeline } from "./timeline"; export type ViewMode = "director" | "camera"; export type RightPanelKind = "scene" | "character" | "prop" | "camera" | "trajectoryPoint"; export type DirectorObjectKind = "character" | "scene" | "prop" | "camera" | "panorama"; export const GEOMETRY_PRIMITIVE_OPTIONS = [ { type: "box", label: "立方体" }, { type: "sphere", label: "球体" }, { type: "cylinder", label: "圆柱体" }, { type: "torus", label: "环状体" }, { type: "cone", label: "圆锥" }, { type: "pyramid", label: "棱锥" }, ] as const; export type GeometryPrimitiveType = (typeof GEOMETRY_PRIMITIVE_OPTIONS)[number]["type"]; export type CharacterRigType = "mannequin" | "ue4-mannequin" | "mixamo" | "vrm" | "custom-humanoid"; export type CharacterBodyType = | "mannequin" | "female" | "broad" | "muscular" | "slim" | "teen" | "child" | "chibi"; export type DirectorAssetKind = "character" | "scene" | "prop" | "panorama"; export type DirectorAssetSource = "local" | "library"; export type PanoramaProjectionMode = "equirectangular" | "backdrop"; export interface DirectorTransform { position: [number, number, number]; rotation: [number, number, number]; scale: [number, number, number]; } /** * 变换的「重置」值:位置 / 旋转归零,缩放恢复为 1。 * 角色卡片、模型卡片等属性面板标题后的重置按钮共用这一份,避免各写一套。 */ export const DEFAULT_DIRECTOR_TRANSFORM: DirectorTransform = { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], }; /** * 地面默认颜色:深灰。 * 默认场景与「旧工程迁移」共用这一份,不要在两处各写一个色值。 */ export const DEFAULT_GROUND_COLOR = "#303640"; export interface SceneSettings { scale: number; position: [number, number, number]; rotation: [number, number, number]; /** 天空(背景)颜色:没接全景图时铺满整个视口 */ backgroundColor: string; panoramaYaw: number; panoramaRadius: number; showLabels: boolean; /** 截图 / 视频导出时是否把视口网格一起带进画面(默认 false:导出时藏起网格,更干净) */ captureShowGrid: boolean; snapToGrid: boolean; showGround: boolean; /** 地面颜色:和天空颜色一样属于工程设置,可在场景面板里改 */ groundColor: string; groundOpacity: number; groundHeight: number; } /** 基础动作类型:none=不应用循环,idle/walk/run 由 ProceduralMannequin 按 time 驱动。 */ export type MotionKind = "none" | "idle" | "walk" | "run"; /** * 单个动作片段:角色在时间轴上某段区间内播放的基础动作(待机 / 行走 / 跑步)。 * 同一角色可叠加多个片段(动作序列,如先走再跑)。片段之间允许重叠, * 播放时「后定义的片段」覆盖先定义的片段(最后覆盖者胜)。 * `end` 为非有限值(Infinity)时表示一直播放到时间轴末尾。 */ export interface MotionClip { id: string; kind: MotionKind; speed: number; amplitude: number; enabled: boolean; /** 片段起点(秒):角色在此之前保持静止。 */ start: number; /** 片段终点(秒):非有限值表示到时间轴末尾。 */ end: number; } /** 单个角色的「动作」配置。当前 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; /** 时间轴上的动作片段序列(多段不重叠动作)。缺省为空 → 回退到单一 `motion` 字段。 */ motionClips?: MotionClip[]; /** 当该角色几何来自「导入的 GLB/GLTF 资产」时,指向该资产 id;渲染用其 url 加载人形模型并驱动动作。 */ modelAssetId?: string; } export interface DirectorAssetRef { id: string; kind: DirectorAssetKind; sourceType: "model" | "image"; fileName: string; name?: string; url: string; assetSource?: DirectorAssetSource; projectionMode?: PanoramaProjectionMode; /** * GLB/GLTF 等二进制模型的 IndexedDB 存储 id。 * 存在时 localStorage 只保存轻量元数据(url 置空),运行时再从 IndexedDB 还原 objectURL, * 避免大文件 base64 撑爆 localStorage。 */ binaryId?: string; } /** * 运动轨迹点:角色在「场景」坐标系里的一个路点。 * * 用户在画布上双击地面打点,新建时定位只取 x / z(y 固定为 0),所以默认轨迹是贴地平面的; * rotation 是该路点处角色的朝向,单位是度,顺序与 DirectorTransform 一致: * [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)]。 * 语义(角色正面朝 +Z、头顶朝 +Y): * - 俯仰角 +90° = 趴着(脸朝下)、-90° = 躺着(脸朝上) * - 水平角 0–360° 是一整圈(360° ≡ 0°) * - 横滚角 -180° – 180° 是一整圈(180° ≡ -180°) */ export interface DirectorTrajectoryPoint { id: string; position: [number, number, number]; rotation: [number, number, number]; /** * 该路点的关键帧时间(秒,0 < time ≤ 时间轴时长)。 * 缺省表示「还没被拖动过」,按轨迹点序号在时间轴上均匀铺开;拖动滑块 / 关键帧后写入实际值。 */ time?: number; } /** 当前选中的轨迹点:轨迹点存在角色身上,所以要同时记所属对象与点的 id。 */ export interface DirectorTrajectoryPointRef { objectId: string; pointId: string; } /** * 朝向角的取值范围(度),下标与 rotation 一致: * - 0 俯仰角 pitch(X):-90 – 90(趴着 ↔ 躺着 / 镜头俯视 ↔ 仰视,物理限位,不是整圈) * - 1 水平角 yaw(Y):0 – 360(绕竖直轴转一整圈) * - 2 横滚角 roll(Z):-180 – 180(绕前后轴侧倾一整圈) * * 轨迹点(DirectorTrajectoryPoint.rotation)与机位注视(DirectorCameraShot.lookRotation) * 共用这一套语义,保证「角色朝向」和「镜头朝向」在面板上的读数是同一套语言。 */ export const ORIENTATION_ROTATION_LIMITS: Record<0 | 1 | 2, { min: number; max: number }> = { 0: { min: -90, max: 90 }, 1: { min: 0, max: 360 }, 2: { min: -180, max: 180 }, }; /** 轨迹点朝向角的取值范围(度):通用朝向角语义的别名 */ export const TRAJECTORY_ROTATION_LIMITS = ORIENTATION_ROTATION_LIMITS; /** 机位注视朝向的取值范围(度):与轨迹点完全一致 */ export const CAMERA_ROTATION_LIMITS = ORIENTATION_ROTATION_LIMITS; export const DEGREES_TO_RADIANS = Math.PI / 180; export const RADIANS_TO_DEGREES = 180 / Math.PI; export function degreesToRadians(degrees: number): number { return degrees * DEGREES_TO_RADIANS; } export function radiansToDegrees(radians: number): number { return radians * RADIANS_TO_DEGREES; } export function degreesRotationToRadians(rotation: [number, number, number]): [number, number, number] { return [degreesToRadians(rotation[0]), degreesToRadians(rotation[1]), degreesToRadians(rotation[2])]; } export function radiansRotationToDegrees(rotation: [number, number, number]): [number, number, number] { return [radiansToDegrees(rotation[0]), radiansToDegrees(rotation[1]), radiansToDegrees(rotation[2])]; } /** * 把角度按整圈收敛到 [min, max];端点保留,避免滑块拖到尽头又跳回另一端: * - 区间内的值原样返回; * - 大于 max 按 (value - min) % (max - min) + min 绕回; * - 小于 min 按 max - (min - value) % (max - min) 绕回。 * 非法值(NaN / 空)按 0 处理(0 落在区间内时)。 */ export function wrapAngleIntoRange(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min <= 0 && max >= 0 ? 0 : min; const span = max - min; if (value > max) return ((value - min) % span) + min; if (value < min) return max - ((min - value) % span); return value; } /** * 把轨迹点朝向角归一化到合法区间: * - 俯仰角(轴 0)是物理限位 [-90, 90],越界直接夹(趴着 / 躺着是端点,不是整圈)。 * - 水平角 / 横滚角是整圈,越界按模收敛(360° ≡ 0°、181° ≡ -179°),端点保留。 * - 非法值按 0 处理。 */ export function normalizeOrientationRotation(axis: 0 | 1 | 2, value: number): number { if (!Number.isFinite(value)) return 0; if (axis === 0) { const { min, max } = ORIENTATION_ROTATION_LIMITS[0]; return Math.min(max, Math.max(min, value)); } const { min, max } = ORIENTATION_ROTATION_LIMITS[axis]; return wrapAngleIntoRange(value, min, max); } /** 轨迹点朝向角归一化(沿用通用朝向角规则,保留原名便于按语义调用) */ export const normalizeTrajectoryRotation = normalizeOrientationRotation; /** 机位注视朝向角归一化(与轨迹点同一套规则:俯仰角夹紧、水平角 / 横滚角按整圈取模) */ export const normalizeCameraRotation = normalizeOrientationRotation; /** 角色 / 道具 / 群众变换旋转的取值范围(度):每个轴都是 0–360° 一整圈。 */ export const TRANSFORM_ROTATION_LIMITS = { min: 0, max: 360 } as const; /** * 把对象变换旋转归一化为「每个轴 0–360° 一整圈」:越界按模绕回,端点保留。 * 对象旋转是通用欧拉角,不区分俯仰/横滚语义;与轨迹点一致,存入的是度数。 */ export function normalizeTransformRotation(rotation: [number, number, number]): [number, number, number] { return [ wrapAngleIntoRange(rotation[0], TRANSFORM_ROTATION_LIMITS.min, TRANSFORM_ROTATION_LIMITS.max), wrapAngleIntoRange(rotation[1], TRANSFORM_ROTATION_LIMITS.min, TRANSFORM_ROTATION_LIMITS.max), wrapAngleIntoRange(rotation[2], TRANSFORM_ROTATION_LIMITS.min, TRANSFORM_ROTATION_LIMITS.max), ]; } export interface DirectorObject { id: string; name: string; kind: DirectorObjectKind; visible: boolean; locked: boolean; transform: DirectorTransform; bodyType?: CharacterBodyType; color?: string; assetRefId?: string; geometryType?: GeometryPrimitiveType; crowdId?: string; crowdLabel?: string; linkedCameraId?: string | null; characterRig?: CharacterRigState; /** 角色身上的运动轨迹点:按创建顺序连成路径,画布上以标记 + 连线呈现。 */ trajectoryPoints?: DirectorTrajectoryPoint[]; /** * 人物轨迹片段的起始时间(秒):0 / 缺省 = 从 0s 就开始走位; * 大于 0 表示角色先在起点原地等待,到这一刻才沿轨迹移动(时间轴上可拖动整条轨迹条平移)。 */ trajectoryStartTime?: number; /** * 人物轨迹片段的结束时间(秒):角色走完最后一个轨迹点后,原地停留到这一刻。 * 缺省 / 为 0 时沿用旧语义(结束时间 = 最后一个轨迹点的时间,最后一个点即时间结束处); * 设置后,最后一个轨迹点只是路径中途的一个途经点,条右边缘到这一刻之间都是「停留」段。 */ trajectoryEndTime?: number; /** * 动作片段的起始时间(秒):角色的基础动作(待机 / 行走 / 跑步)从这一刻开始生效。 * 缺省 / 为 0 表示时间轴一开始就在做这个动作;大于 0 表示动作之前角色保持静止姿态,到这一刻才开始动。 */ motionStartTime?: number; /** * 动作片段的结束时间(秒):动作播放到这一刻停止,角色回到静止姿态。 * 缺省 / 为 0 表示动作一直播到时间轴末尾(即整条时间轴都在做这个动作)。 */ motionEndTime?: number; } export interface DirectorCameraCapture { id: string; index: number; name: string; dataUrl: string; } export interface DirectorCameraShot { id: string; name: string; fov: number; transform: DirectorTransform; targetMode: "manual" | "object"; targetObjectId?: string | null; /** * 机位的注视朝向(度),顺序与 DirectorTrajectoryPoint.rotation 完全一致: * [俯仰角 pitch(X), 水平角 yaw(Y), 横滚角 roll(Z)]。 * 语义(镜头朝 +Z、机顶朝 +Y): * - 俯仰角 +90° = 垂直俯视(镜头朝下)、-90° = 垂直仰视(镜头朝上) * - 水平角 0–360° 是一整圈(360° ≡ 0°) * - 横滚角 -180° – 180° 是一整圈(180° ≡ -180°) * 合成顺序是 YXZ(先横滚、再俯仰、最后水平),保证俯仰 / 横滚始终绕镜头自身轴, * 详细换算见 cameraGeometry。 */ lookRotation: [number, number, number]; lastCaptureUrl?: string | null; captures?: DirectorCameraCapture[]; } // ===== 视频导出设置 ===== /** 编码档位偏好:统一走 H.264(MP4),这里只决定 profile(清晰度 / 兼容性权衡) */ export type ExportCodecPreference = "auto" | "avc-high" | "avc-main" | "avc-baseline"; /** 画质档位:决定目标码率;custom 时改用手填的 customBitrate */ export type ExportQuality = "low" | "medium" | "high" | "custom"; export interface ExportSettings { /** 导出视频短边分辨率(像素):长边按画面比例自动计算 */ resolutionShortEdge: number; /** 画质档位:决定码率(custom 时用手填的 customBitrate) */ quality: ExportQuality; /** 自定义码率(bps),仅 quality === "custom" 时生效 */ customBitrate: number; /** 编码档位偏好(H.264 profile 选择) */ codec: ExportCodecPreference; } /** 短边分辨率档位(像素) */ export const EXPORT_RESOLUTION_OPTIONS: { value: number; label: string }[] = [ { value: 480, label: "480p(短边 480)" }, { value: 720, label: "720p(短边 720)" }, { value: 1080, label: "1080p(短边 1080)" }, { value: 1440, label: "2K(短边 1440)" }, { value: 2160, label: "4K(短边 2160)" }, ]; /** 画质 → 码率系数(相对 宽×高×帧率)。统一 H.264 编码,这里只影响目标码率。 */ export const EXPORT_QUALITY_OPTIONS: { value: ExportQuality; label: string; bitrateMultiplier: number; }[] = [ { value: "low", label: "低(省空间)", bitrateMultiplier: 0.08 }, { value: "medium", label: "中(推荐)", bitrateMultiplier: 0.15 }, { value: "high", label: "高(清晰)", bitrateMultiplier: 0.3 }, { value: "custom", label: "自定义(手动填码率)", bitrateMultiplier: 0 }, ]; /** 编码档位 → H.264 codec 串(auto 时由浏览器挑支持的最高规格) */ export const EXPORT_CODEC_OPTIONS: { value: ExportCodecPreference; label: string; codec: string | null; }[] = [ { value: "auto", label: "自动(挑浏览器支持的最高规格)", codec: null }, { value: "avc-high", label: "H.264 High(高质量)", codec: "avc1.640028" }, { value: "avc-main", label: "H.264 Main(兼容性)", codec: "avc1.4d0028" }, { value: "avc-baseline", label: "H.264 Baseline(最兼容)", codec: "avc1.42001f" }, ]; export const DEFAULT_EXPORT_SETTINGS: ExportSettings = { resolutionShortEdge: 720, quality: "medium", customBitrate: 8_000_000, codec: "auto", }; export interface DirectorProject { version: 2; scene: SceneSettings; assets: DirectorAssetRef[]; objects: DirectorObject[]; cameras: DirectorCameraShot[]; activeCameraId: string | null; panoramaAssetId: string | null; timeline: Timeline; /** 视频导出设置:分辨率 / 码率 / 编码档位,存进工程随工程一起保存 */ exportSettings: ExportSettings; }