394 lines
16 KiB
TypeScript
394 lines
16 KiB
TypeScript
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;
|
||
}
|