Files
ttstd-3d-director/src/editor/schema/directorProject.ts

394 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, number>;
/** 缺省视为 { 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;
}