feat(canvas): 3D 导演台 - 补齐非 P0 分层交互与回归保护 (#333)

* feat(canvas): 3D 导演台 - 补齐分层交互与回归保护

* fix(canvas): 3D 导演台 - 过滤非法轨迹关键帧

* chore(canvas): 3D 导演台 - 对齐前端格式规范

* fix(canvas): 3D 导演台 - 保证故障重试入口可点击

* test(canvas): 3D 导演台 - 稳定真实点击验证
This commit is contained in:
echoD886
2026-08-27 20:47:10 +08:00
committed by GitHub
parent 0abb072273
commit 0b0d4f0774
34 changed files with 5049 additions and 141 deletions

View File

@@ -1,6 +1,6 @@
import { Euler, Quaternion } from "three";
import type { DirectorBoneKeyframe, DirectorKeyframe, DirectorQuat, DirectorTransform, DirectorVec3 } from "../../../types/director";
import type { DirectorBoneKeyframe, DirectorCamera, DirectorKeyframe, DirectorQuat, DirectorTransform, DirectorVec3 } from "../../../types/director";
import { interpolateDirectorBoneRotation, interpolateDirectorTransform, upsertDirectorKeyframe } from "./director-scene";
// 缩放为 0 时无法用比例表达增量,改用绝对偏移;阈值同时兼顾数值噪声。
@@ -121,6 +121,31 @@ export function resolveDirectorKeyframeRecord(input: { base: DirectorTransform;
return { time: input.snappedTime, transform: rendered, keyframes: upsertDirectorKeyframe(input.keyframes, input.snappedTime, rendered) };
}
/**
* 把自由观察相机显式写回实际摄影机。
*
* 没有动画轨道时更新基础 transform;已有轨道时写当前播放头关键帧,否则旧关键帧会
* 继续接管 CAM 取景,让界面提示“已对齐”但画面立即跳回旧位置。
*/
export function resolveDirectorCameraAlignment(camera: DirectorCamera, transform: DirectorTransform, time: number): DirectorCamera {
if (!camera.keyframes.length) return { ...camera, transform };
return { ...camera, keyframes: upsertDirectorKeyframe(camera.keyframes, time, transform) };
}
/** 生成运镜只更新首尾帧;保留用户手工添加的中间帧、帧 id 与 easing。 */
export function resolveDirectorCameraMoveKeyframes(keyframes: DirectorKeyframe[], start: DirectorTransform, end: DirectorTransform, duration: number) {
const endTime = Number.isFinite(duration) && duration > 0 ? duration : 0;
return upsertDirectorKeyframe(upsertDirectorKeyframe(keyframes, 0, start), endTime, end);
}
/** 播放头按镜头时长循环;非法输入回落 0,长帧也保留越界余量。 */
export function advanceDirectorPlayhead(playhead: number, elapsed: number, duration: number) {
if (!Number.isFinite(duration) || duration <= 0) return 0;
const current = Number.isFinite(playhead) ? playhead : 0;
const delta = Number.isFinite(elapsed) && elapsed > 0 ? elapsed : 0;
return (((current + delta) % duration) + duration) % duration;
}
/**
* 暂停且相机没有被关键帧驱动时不写相机,避免与用户 Orbit 操作互相抢夺。
* key 必须包含该 playhead 上真正解算出的 transform,否则「同一 playhead 改关键帧内容」

View File

@@ -0,0 +1,73 @@
import type { DirectorRenderMode } from "@/types/director";
/**
* 导演台一级模式。
*
* mode 是工作台 UI 状态,绝不写入 DirectorScene —— 场景是内容,模式是当前怎么看内容。
* 这里只做纯粹的「模式 -> 能力」映射与切换清理,组件层照着 capabilities 决定显示什么。
*/
export type DirectorMode = "layout" | "pose" | "animate" | "camera";
export type DirectorModeCapabilities = {
/** 时间轴是否显示。只有动画模式显示。 */
timeline: boolean;
/** 是否允许记录/删除关键帧与 Auto Key。只有动画模式允许。 */
keyframes: boolean;
/** 是否展示骨骼与姿势入口。姿态与动画模式展示。 */
bones: boolean;
/** 是否突出 shot/camera 检查器与运镜入口。摄影机模式突出。 */
cameraTools: boolean;
/** 该模式允许的渲染视图。摆场不给深度/法线这类高级视图。 */
renderModes: DirectorRenderMode[];
};
const CAPABILITIES: Record<DirectorMode, DirectorModeCapabilities> = {
// 摆场:先把人和物放对位置,隐藏骨骼、关键帧、Auto Key、深度/法线。
layout: { timeline: false, keyframes: false, bones: false, cameraTools: false, renderModes: ["beauty", "clay"] },
// 姿态:调骨骼与姿势,但不默认制造关键帧。
pose: { timeline: false, keyframes: false, bones: true, cameraTools: false, renderModes: ["beauty", "clay", "pose"] },
// 动画:唯一显示时间轴、允许 Auto Key 与录制关键帧的模式。
animate: { timeline: true, keyframes: true, bones: true, cameraTools: false, renderModes: ["beauty", "clay", "pose"] },
// 摄影机:突出机位、对齐视图与运镜;深度/法线在这里才有意义。
camera: { timeline: false, keyframes: false, bones: false, cameraTools: true, renderModes: ["beauty", "clay", "depth", "normal"] },
};
export const DIRECTOR_MODES: Array<{ mode: DirectorMode; label: string; hint: string }> = [
{ mode: "layout", label: "摆场", hint: "摆放演员、道具与灯光" },
{ mode: "pose", label: "姿态", hint: "调整选中演员的骨骼与姿势" },
{ mode: "animate", label: "动画", hint: "时间轴、关键帧与 Auto Key" },
{ mode: "camera", label: "摄影机", hint: "机位、对齐视图与运镜" },
];
export const DIRECTOR_DEFAULT_MODE: DirectorMode = "layout";
export function directorModeCapabilities(mode: DirectorMode): DirectorModeCapabilities {
return CAPABILITIES[mode] ?? CAPABILITIES[DIRECTOR_DEFAULT_MODE];
}
export type DirectorModeTransition = {
mode: DirectorMode;
playing: boolean;
autoKey: boolean;
renderMode: DirectorRenderMode;
};
/**
* 切换模式时必须一起收敛的运行时状态。
*
* 关键安全边界:离开动画模式一定停播 + 关 Auto Key。
* 否则播放循环仍在推进 playhead,而 Auto Key 会在用户以为只是「换了个模式」时
* 继续往场景里写关键帧 —— 那是后台偷偷改内容。
*
* renderMode 同时被夹回目标模式允许的集合:不能带着深度视图退回摆场。
*/
export function resolveDirectorModeTransition(input: { mode: DirectorMode; playing: boolean; autoKey: boolean; renderMode: DirectorRenderMode }): DirectorModeTransition {
const capabilities = directorModeCapabilities(input.mode);
return {
mode: input.mode,
playing: capabilities.timeline ? input.playing : false,
autoKey: capabilities.keyframes ? input.autoKey : false,
renderMode: capabilities.renderModes.includes(input.renderMode) ? input.renderMode : "beauty",
};
}

View File

@@ -0,0 +1,246 @@
import { localForageStorageForScope } from "@/lib/localforage-storage";
import type { DirectorMode } from "@/lib/canvas/director/director-modes";
/**
* 导演台首次上手引导(P2)。
*
* 为什么存在:导演台把模板、四个一级模式、对象操作、保存/导出堆在同一个工作台里,
* 第一次进来的人没有任何线索该先做什么。引导给出一条确定的最短路径。
*
* 硬约束:
* - 纯状态机 + 持久化,绝不触碰 DirectorScene,也不写 undo/redo 历史:
* 引导是「用户看到什么提示」,不是场景内容。
* - 进度按用户隔离。所有导出 API 必须显式收到非空 scope,禁止内部兜底成 guest ——
* 默认 scope 会让 A 账号的引导状态泄漏给 B 账号。
* - 只用 localforage(走 localForageStorageForScope),不用 localStorage:
* 与项目里其他用户态持久化保持同一条通道。
* - 缺失、损坏、版本不符的持久数据一律回落到初始进度;只有存储层本身失败才向上抛。
*/
export type DirectorOnboardingStepId = "actor" | "move" | "pose" | "path" | "camera" | "apply";
/** active 才展示。dismissed(用户跳过)与 completed(走完)都不再展示,且不可被 next/back 唤醒。 */
export type DirectorOnboardingStatus = "active" | "dismissed" | "completed";
/**
* 进度 schema 版本。
* 步骤集合发生语义变化(增删步骤、改变顺序)时必须 +1:
* 旧进度里的 stepId 在新步骤表里可能不存在,继续沿用会把用户卡在不存在的步骤上。
*/
export const DIRECTOR_ONBOARDING_VERSION = 2;
/** localforage 键名。真实键由 localForageStorageForScope 追加 `:user:<scope>`。 */
export const DIRECTOR_ONBOARDING_KEY = "director-onboarding-v2";
export type DirectorOnboardingStep = {
id: DirectorOnboardingStepId;
title: string;
/** 一句话说清「在哪儿点什么」,不解释概念。 */
detail: string;
/**
* 该步骤应该在哪个一级模式里完成。
* 模式巡览步骤本身没有单一归属,故为 undefined —— 由 UI 决定是否代为切换模式。
*/
mode?: DirectorMode;
};
/** 顺序与 Issue #305 的首次任务一致:添加演员 → 移动 → 调姿 → 轨迹 → CAM → 应用。 */
export const DIRECTOR_ONBOARDING_STEPS: DirectorOnboardingStep[] = [
{ id: "actor", title: "添加并选中演员", detail: "在左侧「快速添加」点演员;模板已有演员时,直接在场景树或视口选中它。", mode: "layout" },
{ id: "move", title: "把演员移动到表演位置", detail: "保持摆场模式,在底部 dock 选移动工具(W),拖动轴手柄;右栏可输入精确坐标。", mode: "layout" },
{ id: "pose", title: "调整演员姿态", detail: "切到姿态模式,在右侧选择姿势预设;模型有骨骼时还可选择手臂、腿和躯干微调。", mode: "pose" },
{ id: "path", title: "记录一段走位", detail: "切到动画模式,在起点和终点分别移动播放头、摆放演员并记录 Transform 关键帧,再播放预览。", mode: "animate" },
{ id: "camera", title: "用 CAM 检查构图", detail: "切到摄影机模式,再点视口工具栏 CAM;调整焦距、焦点和机位,确认安全框内的画面。", mode: "camera" },
{ id: "apply", title: "应用到镜头", detail: "确认场景、姿态、走位和 CAM 构图后,点顶栏「应用到镜头」生成静帧并回写画布。", mode: "camera" },
];
export type DirectorOnboardingProgress = {
version: number;
status: DirectorOnboardingStatus;
stepId: DirectorOnboardingStepId;
};
export const directorOnboardingInitial: DirectorOnboardingProgress = {
version: DIRECTOR_ONBOARDING_VERSION,
status: "active",
stepId: DIRECTOR_ONBOARDING_STEPS[0].id,
};
export type DirectorOnboardingAction = "next" | "back" | "dismiss" | "complete" | "reset";
/**
* 引导状态机。
*
* 关键不变量:
* - 只有 reset 能让已 dismissed/completed 的引导重新活过来。next/back/dismiss/complete
* 在非 active 状态下是恒等变换,否则「跳过引导」会被后续任何一次误触重新拉起来。
* - 在最后一步 next 等于完成:完成必须是终态,而不是停在最后一步继续可 next。
* - 首步 back 保持不动,不越界,也不隐式关闭引导。
* - 当前 stepId 不在步骤表里(旧数据、被外部改写)时回到初始进度,而不是把 -1 当下标继续算。
*/
export function reduceDirectorOnboarding(progress: DirectorOnboardingProgress, action: DirectorOnboardingAction): DirectorOnboardingProgress {
if (action === "reset") return directorOnboardingInitial;
if (progress.status !== "active") return progress;
if (action === "dismiss") return { ...progress, status: "dismissed" };
if (action === "complete") return { ...progress, status: "completed" };
const index = DIRECTOR_ONBOARDING_STEPS.findIndex((step) => step.id === progress.stepId);
if (index < 0) return directorOnboardingInitial;
if (action === "back") {
if (index === 0) return progress;
return { ...progress, stepId: DIRECTOR_ONBOARDING_STEPS[index - 1].id };
}
const next = DIRECTOR_ONBOARDING_STEPS[index + 1];
if (!next) return { ...progress, status: "completed" };
return { ...progress, stepId: next.id };
}
export type DirectorOnboardingView = {
step: DirectorOnboardingStep;
/** 1 起数,直接用于「第 N 步 / 共 M 步」。 */
position: number;
total: number;
isFirst: boolean;
isLast: boolean;
};
/**
* 展示态。返回 null 表示不该显示引导 —— 组件层只看这一个判据,
* 不自己再拼 status 与步骤下标,避免出现第二套可见性规则。
*/
export function resolveDirectorOnboardingView(progress: DirectorOnboardingProgress): DirectorOnboardingView | null {
if (progress.status !== "active") return null;
const index = DIRECTOR_ONBOARDING_STEPS.findIndex((step) => step.id === progress.stepId);
if (index < 0) return null;
return {
step: DIRECTOR_ONBOARDING_STEPS[index],
position: index + 1,
total: DIRECTOR_ONBOARDING_STEPS.length,
isFirst: index === 0,
isLast: index === DIRECTOR_ONBOARDING_STEPS.length - 1,
};
}
function isStepId(value: unknown): value is DirectorOnboardingStepId {
return typeof value === "string" && DIRECTOR_ONBOARDING_STEPS.some((step) => step.id === value);
}
function isStatus(value: unknown): value is DirectorOnboardingStatus {
return value === "active" || value === "dismissed" || value === "completed";
}
/**
* 解析持久进度。
*
* 引导是提示,不是内容:任何无法确信的持久值都当成「没有进度」,宁可再引导一次,
* 也不要把用户卡在一个无法解释的状态里。覆盖:非 JSON、非对象、数组、版本不符、
* status/stepId 不在枚举内。
*/
export function parseDirectorOnboardingProgress(raw: string | null | undefined): DirectorOnboardingProgress {
if (!raw) return directorOnboardingInitial;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return directorOnboardingInitial;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return directorOnboardingInitial;
const candidate = parsed as Partial<DirectorOnboardingProgress>;
if (candidate.version !== DIRECTOR_ONBOARDING_VERSION) return directorOnboardingInitial;
if (!isStatus(candidate.status) || !isStepId(candidate.stepId)) return directorOnboardingInitial;
return { version: DIRECTOR_ONBOARDING_VERSION, status: candidate.status, stepId: candidate.stepId };
}
/** 只要求读写能力:既是测试注入内存实现的接缝,也避免依赖 zustand StateStorage 的全量接口。 */
export type DirectorOnboardingStorage = {
getItem: (name: string) => string | null | Promise<string | null>;
setItem: (name: string, value: string) => void | Promise<void>;
};
/**
* scope 归一化 + 守卫。前后空白裁掉后再校验/使用,这样 " user-a " 与 "user-a"
* 落到同一把生产键上,不会分裂成两份互不可见的进度。
*
* 必须在每个导出的持久化函数入口无条件调用 —— 包括调用方已经注入自定义 storage 的分支。
* 之前的实现写成 `storage ?? localForageStorageForScope(requireScope(scope))`,
* `??` 短路导致「传了 storage」时 requireScope 根本不会执行,空 scope 就此绕过校验。
*/
function requireScope(scope: string) {
const normalized = scope.trim();
if (!normalized) throw new Error("缺少导演台引导的用户 scope");
return normalized;
}
/**
* 读取进度。
* 数据脏 -> 初始进度;存储层(IndexedDB)失败 -> 向上抛,由调用方决定是否隐藏引导,
* 不在这里 catch 成默认值假装读到了。
*/
export async function loadDirectorOnboardingProgress(scope: string, storage?: DirectorOnboardingStorage): Promise<DirectorOnboardingProgress> {
const normalizedScope = requireScope(scope);
const target = storage ?? localForageStorageForScope(normalizedScope);
return parseDirectorOnboardingProgress(await target.getItem(DIRECTOR_ONBOARDING_KEY));
}
export async function saveDirectorOnboardingProgress(scope: string, progress: DirectorOnboardingProgress, storage?: DirectorOnboardingStorage): Promise<void> {
const normalizedScope = requireScope(scope);
const target = storage ?? localForageStorageForScope(normalizedScope);
await target.setItem(DIRECTOR_ONBOARDING_KEY, JSON.stringify({ version: DIRECTOR_ONBOARDING_VERSION, status: progress.status, stepId: progress.stepId }));
}
/**
* 推进并持久化。返回新进度;写失败向上抛,调用方拿到的进度与已落盘的进度保持一致,
* 不存在「界面前进了但磁盘没动」这种静默分叉。
*
* scope 校验必须在「无变化动作提前返回」之前完成:否则对已 dismissed/completed 账号
* 调用 next 之类的恒等动作会在校验之前就 return progress,空 scope 也能悄悄跑过去。
* 无变化的动作仍然不写盘。
*/
export async function advanceDirectorOnboarding(scope: string, progress: DirectorOnboardingProgress, action: DirectorOnboardingAction, storage?: DirectorOnboardingStorage): Promise<DirectorOnboardingProgress> {
const normalizedScope = requireScope(scope);
const next = reduceDirectorOnboarding(progress, action);
if (next === progress) return progress;
await saveDirectorOnboardingProgress(normalizedScope, next, storage);
return next;
}
/**
* 独立的重启 API。不依赖调用方持有当前内存进度 —— 未来工作台设置里的
* 「重新开始引导」按钮不必先加载一次旧进度才能重置,直接落盘初始状态即可,
* 对已 dismissed/completed 的账号同样生效。
*/
export async function resetDirectorOnboardingProgress(scope: string, storage?: DirectorOnboardingStorage): Promise<DirectorOnboardingProgress> {
const normalizedScope = requireScope(scope);
await saveDirectorOnboardingProgress(normalizedScope, directorOnboardingInitial, storage);
return directorOnboardingInitial;
}
export type DirectorOnboardingGate = {
/** 尝试进入互斥区;已有一次未完成的调用时返回 false,调用方必须放弃本次动作。 */
tryEnter: () => boolean;
/** 释放互斥区。scope/enabled 变化(换账号)时也要调用,否则旧账号的占用会永久卡死新账号。 */
release: () => void;
};
/**
* 创建一次性写锁:保证同一 tick 内的重复调用(双击、程序化连点)只有一个能真正发起写入。
*
* 用同步闭包变量而不是 React state 做门禁:state 更新要等到下一次渲染才对外可见,
* 两次点击完全可能发生在同一次渲染之间,仅靠 busy state 挡不住这种同步重入。
* release 是幂等的:未锁定时调用不抛错,方便在 effect 里无条件清理。
*/
export function createDirectorOnboardingGate(): DirectorOnboardingGate {
let locked = false;
return {
tryEnter: () => {
if (locked) return false;
locked = true;
return true;
},
release: () => {
locked = false;
},
};
}

View File

@@ -1,7 +1,7 @@
import { nanoid } from "nanoid";
import { Color, Euler, Quaternion } from "three";
import type { DirectorBoneKeyframe, DirectorBoneTrack, DirectorCamera, DirectorHumanoidBone, DirectorKeyframe, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director";
import type { DirectorBoneKeyframe, DirectorBoneTrack, DirectorCamera, DirectorHumanoidBone, DirectorKeyframe, DirectorKeyframeDeleteTarget, DirectorKeyframeEasing, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director";
export const DIRECTOR_DEFAULT_ACTOR_URL = "https://cdn.jsdelivr.net/gh/mrdoob/three.js@r185/examples/models/gltf/Xbot.glb";
export const DIRECTOR_ACTOR_COLORS = ["#f1f3f5", "#202329", "#2f7de1", "#d84949", "#dfae3f", "#34a276"] as const;
@@ -72,6 +72,11 @@ export function createDirectorCamera(name = "主摄影机"): DirectorCamera {
return { id: nanoid(), name, transform: directorIdentityTransform([4.8, 2.7, 6.8]), target: [0, 1, 0], focalLength: 35, fov: 50, aperture: 2.8, focusDistance: 5, near: 0.05, far: 500, keyframes: [] };
}
/** 35mm 全画幅水平视角换算。摄影机检查器与场景模板共用,避免两处各写一份光学。 */
export function directorFocalLengthToFov(focalLength: number) {
return (2 * Math.atan(36 / (2 * Math.max(1, focalLength))) * 180) / Math.PI;
}
export function createDirectorLight(type: DirectorLight["type"], name: string, position: DirectorVec3, intensity = 1): DirectorLight {
return { id: nanoid(), name, type, transform: directorIdentityTransform(position), color: "#ffffff", intensity, angle: Math.PI / 4, penumbra: 0.35, castShadow: type !== "ambient" };
}
@@ -80,8 +85,11 @@ export function touchDirectorScene(scene: DirectorScene): DirectorScene {
return { ...scene, updatedAt: new Date().toISOString() };
}
/** 关键帧命中容差:upsert 与 remove 必须同判据,否则会出现「记录能覆盖但删不掉」。 */
export const DIRECTOR_KEYFRAME_EPSILON = 0.001;
export function upsertDirectorKeyframe(keyframes: DirectorKeyframe[], time: number, transform: DirectorTransform) {
const current = keyframes.find((item) => Math.abs(item.time - time) < 0.001);
const current = keyframes.find((item) => Math.abs(item.time - time) < DIRECTOR_KEYFRAME_EPSILON);
const next = current ? keyframes.map((item) => (item.id === current.id ? { ...item, transform } : item)) : [...keyframes, { id: nanoid(), time, transform }];
return next.toSorted((a, b) => a.time - b.time);
}
@@ -93,17 +101,128 @@ export function upsertDirectorBoneKeyframe(tracks: DirectorBoneTrack[], bone: Di
}
function upsertBoneKeyframe(keyframes: DirectorBoneKeyframe[], time: number, rotation: DirectorQuat) {
const current = keyframes.find((item) => Math.abs(item.time - time) < 0.001);
const current = keyframes.find((item) => Math.abs(item.time - time) < DIRECTOR_KEYFRAME_EPSILON);
const next = current ? keyframes.map((item) => item.id === current.id ? { ...item, rotation } : item) : [...keyframes, { id: nanoid(), time, rotation }];
return next.toSorted((a, b) => a.time - b.time);
}
/** 按 id 删除对象 transform 关键帧;id 不存在时返回原数组引用。 */
export function removeDirectorKeyframe(keyframes: DirectorKeyframe[], keyframeId: string): DirectorKeyframe[] {
if (!keyframes.some((item) => item.id === keyframeId)) return keyframes;
return keyframes.filter((item) => item.id !== keyframeId);
}
/**
* 按 id 删除某骨骼轨道上的关键帧。
* 轨道被删空后整条移除,否则时间轴会留下永远为空的子轨道。
*/
export function removeDirectorBoneKeyframe(tracks: DirectorBoneTrack[], bone: DirectorHumanoidBone, keyframeId: string): DirectorBoneTrack[] {
const track = tracks.find((item) => item.bone === bone);
if (!track?.keyframes.some((item) => item.id === keyframeId)) return tracks;
const nextKeyframes = track.keyframes.filter((item) => item.id !== keyframeId);
if (!nextKeyframes.length) return tracks.filter((item) => item.bone !== bone);
return tracks.map((item) => (item.bone === bone ? { ...item, keyframes: nextKeyframes } : item));
}
/**
* 时间轴删除关键帧的唯一入口:按轨道类型分派到对应领域函数。
*
* 未命中(对象/摄影机/骨骼/关键帧任一不存在)时返回同一个 scene 引用,
* 调用方据此跳过历史与保存,避免「点了没删掉却多一次修订」。
*/
export function removeDirectorSceneKeyframe(scene: DirectorScene, target: DirectorKeyframeDeleteTarget): DirectorScene {
if (target.track === "camera") {
const camera = scene.cameras.find((item) => item.id === target.cameraId);
if (!camera) return scene;
const keyframes = removeDirectorKeyframe(camera.keyframes, target.keyframeId);
if (keyframes === camera.keyframes) return scene;
return { ...scene, cameras: scene.cameras.map((item) => (item.id === camera.id ? { ...item, keyframes } : item)) };
}
const object = scene.objects.find((item) => item.id === target.objectId);
if (!object) return scene;
if (target.track === "object-transform") {
const keyframes = removeDirectorKeyframe(object.keyframes, target.keyframeId);
if (keyframes === object.keyframes) return scene;
return { ...scene, objects: scene.objects.map((item) => (item.id === object.id ? { ...item, keyframes } : item)) };
}
const tracks = object.boneTracks || [];
const boneTracks = removeDirectorBoneKeyframe(tracks, target.bone, target.keyframeId);
if (boneTracks === tracks) return scene;
return { ...scene, objects: scene.objects.map((item) => (item.id === object.id ? { ...item, boneTracks } : item)) };
}
/**
* 更新一枚关键帧后续区间的缓动。未命中时返回原 scene 引用,调用方据此跳过历史与保存。
*/
export function setDirectorSceneKeyframeEasing(scene: DirectorScene, target: DirectorKeyframeDeleteTarget, easing: DirectorKeyframeEasing): DirectorScene {
const update = <T extends { id: string; easing?: DirectorKeyframeEasing }>(keyframes: T[]) => {
if (!keyframes.some((item) => item.id === target.keyframeId)) return keyframes;
return keyframes.map((item) => item.id === target.keyframeId ? { ...item, easing } : item);
};
if (target.track === "camera") {
const camera = scene.cameras.find((item) => item.id === target.cameraId);
if (!camera) return scene;
const keyframes = update(camera.keyframes);
if (keyframes === camera.keyframes) return scene;
return { ...scene, cameras: scene.cameras.map((item) => item.id === camera.id ? { ...item, keyframes } : item) };
}
const object = scene.objects.find((item) => item.id === target.objectId);
if (!object) return scene;
if (target.track === "object-transform") {
const keyframes = update(object.keyframes);
if (keyframes === object.keyframes) return scene;
return { ...scene, objects: scene.objects.map((item) => item.id === object.id ? { ...item, keyframes } : item) };
}
const tracks = object.boneTracks || [];
const track = tracks.find((item) => item.bone === target.bone);
if (!track) return scene;
const keyframes = update(track.keyframes);
if (keyframes === track.keyframes) return scene;
const boneTracks = tracks.map((item) => item.bone === target.bone ? { ...item, keyframes } : item);
return { ...scene, objects: scene.objects.map((item) => item.id === object.id ? { ...item, boneTracks } : item) };
}
/** 缓动属于前一枚关键帧到下一枚关键帧的区间。旧数据未声明时保持线性。 */
export function resolveDirectorKeyframeProgress(progress: number, easing: DirectorKeyframeEasing = "linear") {
const clamped = Math.max(0, Math.min(1, progress));
if (easing === "step") return 0;
if (easing === "smooth") return clamped * clamped * (3 - 2 * clamped);
return clamped;
}
/** 轨迹渲染只接受有限时间与位置;坏数据不得进入 Three 几何体。 */
export function finiteDirectorTransformKeyframes(keyframes: DirectorKeyframe[]) {
return keyframes.filter((keyframe) => [keyframe.time, ...keyframe.transform.position].every(Number.isFinite));
}
/** 按时间顺序累计 Transform 关键帧路径长度;非法时间或坐标段忽略,不污染界面统计。 */
export function directorTransformPathLength(keyframes: DirectorKeyframe[]) {
const sorted = keyframes.toSorted((left, right) => left.time - right.time);
let length = 0;
for (let index = 1; index < sorted.length; index += 1) {
const previousTime = sorted[index - 1].time;
const currentTime = sorted[index].time;
const previous = sorted[index - 1].transform.position;
const current = sorted[index].transform.position;
if (![previousTime, currentTime, ...previous, ...current].every(Number.isFinite)) continue;
length += Math.hypot(current[0] - previous[0], current[1] - previous[1], current[2] - previous[2]);
}
return length;
}
export function interpolateDirectorTransform(base: DirectorTransform, keyframes: DirectorKeyframe[], time: number): DirectorTransform {
if (!keyframes.length) return base;
const previous = [...keyframes].reverse().find((item) => item.time <= time) || keyframes[0];
const next = keyframes.find((item) => item.time >= time) || keyframes[keyframes.length - 1];
if (previous.id === next.id) return previous.transform;
const progress = Math.max(0, Math.min(1, (time - previous.time) / Math.max(next.time - previous.time, 0.001)));
const progress = resolveDirectorKeyframeProgress((time - previous.time) / Math.max(next.time - previous.time, DIRECTOR_KEYFRAME_EPSILON), previous.easing);
const rotation = new Quaternion().setFromEuler(new Euler(...previous.transform.rotation)).slerp(new Quaternion().setFromEuler(new Euler(...next.transform.rotation)), progress);
return {
position: lerpVec3(previous.transform.position, next.transform.position, progress),
@@ -117,7 +236,7 @@ export function interpolateDirectorBoneRotation(base: DirectorQuat, keyframes: D
const previous = [...keyframes].reverse().find((item) => item.time <= time) || keyframes[0];
const next = keyframes.find((item) => item.time >= time) || keyframes[keyframes.length - 1];
if (previous.id === next.id) return previous.rotation;
const progress = Math.max(0, Math.min(1, (time - previous.time) / Math.max(next.time - previous.time, 0.001)));
const progress = resolveDirectorKeyframeProgress((time - previous.time) / Math.max(next.time - previous.time, DIRECTOR_KEYFRAME_EPSILON), previous.easing);
return new Quaternion(...previous.rotation).slerp(new Quaternion(...next.rotation), progress).toArray() as DirectorQuat;
}

View File

@@ -14,3 +14,22 @@ export function shouldReinitializeDirectorSession(input: { initializedSceneId: s
export function upsertDirectorSceneById(scenes: DirectorScene[], scene: DirectorScene): DirectorScene[] {
return scenes.some((item) => item.id === scene.id) ? scenes.map((item) => (item.id === scene.id ? scene : item)) : [...scenes, scene];
}
/**
* 输出上传可能跨越数秒。只把生成出的预览引用合并到最新场景,绝不拿输出开始时的
* scene 快照覆盖期间发生的标题、对象、关键帧或镜头编辑。
*/
export function mergeDirectorOutputPreview(scene: DirectorScene, input: { sceneId: string; shotId: string; previewNodeId: string }) {
if (scene.id !== input.sceneId || !scene.shots.some((shot) => shot.id === input.shotId)) return null;
return {
...scene,
shots: scene.shots.map((shot) => (shot.id === input.shotId ? { ...shot, previewNodeId: input.previewNodeId, depthNodeId: undefined, normalNodeId: undefined } : shot)),
};
}
/** 长截图/录制结束前,场景引用与活动镜头必须仍是操作开始时的快照。 */
export function isDirectorOutputSnapshotCurrent(scene: DirectorScene | null, expected: { scene: DirectorScene; shotId: string }) {
if (scene !== expected.scene) return false;
const activeShot = scene.shots.find((shot) => shot.id === scene.activeShotId) || scene.shots[0];
return activeShot?.id === expected.shotId;
}

View File

@@ -0,0 +1,163 @@
/**
* 导演台键盘快捷键解析。
*
* 纯函数:只把「按键事件形状」映射为语义动作,不碰 DOM、不改场景。
* 组件层负责注册监听与执行动作,因此这里可以完整单测。
*
* 安全边界:焦点落在任何交互控件(输入框、按钮、链接、role 型控件、
* 可编辑区域)或其内部时,绝不把按键解析为全局快捷键。
* 否则用户改名时会误删对象、按 Space 激活按钮时会误切换播放。
*/
/**
* 语义动作。只保留导演台里已经存在真实执行路径的动作:
* 变换模式(DirectorViewportDock 的 W/E/R)、删除选中对象或灯光、
* 撤销/重做(顶栏按钮)、切换选中对象显隐(检查器「可见」开关)、
* 取消选择、播放/暂停(时间轴 transport)。
*
* 聚焦、复制、重命名没有对应实现,不在这里造无处可去的动作。
*/
export type DirectorShortcutAction = { kind: "transform-mode"; mode: "translate" | "rotate" | "scale" } | { kind: "delete-selected" } | { kind: "undo" } | { kind: "redo" } | { kind: "toggle-visibility" } | { kind: "deselect" } | { kind: "toggle-play" };
/** 只取事件的必要形状,便于测试构造,也避免耦合真实 KeyboardEvent。 */
export type DirectorKeyEvent = {
key: string;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
/** 事件目标是否落在交互控件内,由调用方判定后传入。 */
isInteractiveTarget?: boolean;
};
const TRANSFORM_KEYS: Record<string, "translate" | "rotate" | "scale"> = {
w: "translate",
e: "rotate",
r: "scale",
};
/**
* 解析按键为动作;不匹配返回 null。
*
* 约定:
* - 交互控件语境一律不解析,交还给控件本身;
* - 带修饰键的组合优先(Undo/Redo),避免 Ctrl+R 被当作 scale;
* - 单字母快捷键不区分大小写,Shift+W 仍是 translate;
* - Redo 同时接受 Ctrl/Cmd+Shift+Z 与 Ctrl/Cmd+Y。
*/
export function resolveDirectorShortcut(event: DirectorKeyEvent): DirectorShortcutAction | null {
if (event.isInteractiveTarget) return null;
const key = String(event.key || "");
const lower = key.toLowerCase();
const accel = Boolean(event.ctrlKey || event.metaKey);
if (accel) {
// Alt 组合留给浏览器/系统,不抢。
if (event.altKey) return null;
if (lower === "z") return event.shiftKey ? { kind: "redo" } : { kind: "undo" };
if (lower === "y") return { kind: "redo" };
return null;
}
if (event.altKey) return null;
if (lower in TRANSFORM_KEYS) return { kind: "transform-mode", mode: TRANSFORM_KEYS[lower] };
if (key === "Delete" || key === "Backspace") return { kind: "delete-selected" };
if (lower === "h") return { kind: "toggle-visibility" };
if (key === "Escape") return { kind: "deselect" };
if (key === " " || key === "Spacebar") return { kind: "toggle-play" };
return null;
}
/**
* 交互控件的唯一真源。
* DIRECTOR_INTERACTIVE_SELECTOR(真实 DOM 的 closest 用)与
* isDirectorInteractiveNode(单节点判定)都从这里派生,避免两处清单漂移。
*/
const INTERACTIVE_TAGS = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A", "OPTION", "LABEL", "SUMMARY"] as const;
/**
* role 型控件:AntD 大量用 role 而非原生标签表达按钮、开关、菜单项。
* Space/Delete/Backspace 对这些控件都有自己的含义,必须让它们优先。
*/
const INTERACTIVE_ROLES = [
"textbox",
"combobox",
"spinbutton",
"searchbox",
"button",
"switch",
"tab",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"slider",
"checkbox",
"radio",
"link",
"listbox",
"menu",
"radiogroup",
"treeitem",
] as const;
/** 供真实 DOM 的 Element.closest 使用:命中控件自身或任何交互祖先。 */
export const DIRECTOR_INTERACTIVE_SELECTOR = [...INTERACTIVE_TAGS.map((tag) => tag.toLowerCase()), ...INTERACTIVE_ROLES.map((role) => `[role="${role}"]`), '[contenteditable="true"]', '[contenteditable=""]'].join(",");
/**
* 判断单个节点自身是否是交互控件。不看祖先。
* 祖先由 blocksDirectorShortcut 通过 closest 处理。
*/
export function isDirectorInteractiveNode(target: unknown): boolean {
if (!target || typeof target !== "object") return false;
const node = target as { tagName?: unknown; isContentEditable?: unknown; getAttribute?: (name: string) => string | null };
if (node.isContentEditable === true) return true;
const tagName = typeof node.tagName === "string" ? node.tagName.toUpperCase() : "";
if ((INTERACTIVE_TAGS as readonly string[]).includes(tagName)) return true;
if (typeof node.getAttribute === "function") {
const role = node.getAttribute("role");
if (role && (INTERACTIVE_ROLES as readonly string[]).includes(role)) return true;
const editable = node.getAttribute("contenteditable");
if (editable === "" || editable === "true") return true;
}
return false;
}
/**
* 事件目标是否应当阻止全局快捷键。
*
* 用 closest 而不是只看 target 自身:点击按钮里的 <svg>/<span> 时
* event.target 是子元素,只判自身会漏掉整个控件。
* closest 同时覆盖 SVG 元素(SVGElement 也实现 Element.closest)。
*
* 没有 closest 的目标(window、document、测试替身)退化为单节点判定。
*/
export function blocksDirectorShortcut(target: unknown): boolean {
if (!target || typeof target !== "object") return false;
const node = target as { closest?: (selector: string) => unknown };
if (typeof node.closest === "function") return Boolean(node.closest(DIRECTOR_INTERACTIVE_SELECTOR));
return isDirectorInteractiveNode(target);
}
/**
* 指针激活后释放焦点,使全局快捷键立刻可用。
*
* 为什么需要:blocksDirectorShortcut 会吃掉聚焦控件上的所有全局快捷键(这是刻意的,
* Space/Delete/Backspace 属于控件自己)。但鼠标点完一个工具按钮或场景行之后,
* 焦点仍留在按钮上,于是「点选对象 -> 按 Delete」这类主流程会静默失效。
*
* detail === 0 表示键盘激活(Enter/Space 合成的 click),此时必须保留焦点,
* 否则 Tab 序列会断、键盘用户会丢失位置。判据与 plugins 页一致。
*
* 导演台的按钮分散在 workbench、dock、sequencer 三处,各自没有公共基组件,
* 因此这条规则集中在这里,避免每个 onClick 各写一遍而慢慢走偏。
*/
export function releaseDirectorFocusAfterPointer(event: { detail: number; currentTarget: { blur?: () => void } }): void {
if (event.detail === 0) return;
event.currentTarget.blur?.();
}

View File

@@ -0,0 +1,144 @@
import { nanoid } from "nanoid";
import { createDirectorActor, createDirectorCamera, createDirectorLight, createDirectorObject, directorFocalLengthToFov, DIRECTOR_ACTOR_COLORS } from "@/lib/canvas/director/director-scene";
import type { DirectorCamera, DirectorLight, DirectorObject, DirectorScene, DirectorShot } from "@/types/director";
/**
* 新建场景模板。
*
* 为什么存在:createDirectorScene 无条件塞一个默认演员,产品/空场景根本不需要它,
* 而双人、走位这类常见开局每次都要手工摆一遍。模板把「开局布局」变成显式选择。
*
* 硬约束:
* - 纯函数 + 确定性布局。同一模板每次生成的结构完全一致,只有 id 与时间戳不同。
* - 每次调用都产生独立 id(工厂内部 nanoid),两个实例不会共享对象身份。
* - 不改 DirectorScene schema/version:模板只是预填内容,不引入新字段。
* - 对象之间 XZ 占位不重叠,判据与 resolveDirectorPlacement 完全一致。
*/
export type DirectorTemplateId = "empty" | "monologue" | "dialogue" | "blocking" | "product";
export type DirectorTemplate = {
id: DirectorTemplateId;
name: string;
description: string;
/** 列表里给用户的预期提示,不参与生成。 */
summary: string;
};
/** 展示顺序即用户看到的顺序:空场景在最前,其余按上手复杂度递增。 */
export const DIRECTOR_TEMPLATES: DirectorTemplate[] = [
{ id: "empty", name: "空场景", description: "只有摄影机和三点布光,没有任何演员或道具。", summary: "摄影机 + 布光" },
{ id: "monologue", name: "单人对白", description: "一名演员居中,中近景机位,适合独白与特写。", summary: "1 演员 · 中近景" },
{ id: "dialogue", name: "双人对话", description: "两名演员面对面分立左右,过肩机位起手。", summary: "2 演员 · 对话机位" },
{ id: "blocking", name: "人物走位", description: "三名演员分散站位,广角机位便于安排走动路线。", summary: "3 演员 · 广角" },
{ id: "product", name: "产品/道具镜头", description: "产品置于中心,背景板与侧面道具就位,长焦特写。", summary: "产品 + 背景板 · 长焦" },
];
type TemplateBlueprint = {
objects: DirectorObject[];
camera: DirectorCamera;
shot: Omit<DirectorShot, "id" | "cameraId">;
};
/**
* 三点布光:主光、轮廓光、环境光。
* 五个模板共用同一套光位,避免模板之间打光风格漂移。
*/
function threePointRig(): DirectorLight[] {
return [createDirectorLight("directional", "主光", [4, 6, 4], 2.4), createDirectorLight("directional", "轮廓光", [-4, 3, -2], 1.1), createDirectorLight("ambient", "环境光", [0, 0, 0], 0.65)];
}
/** 按模板意图摆机位。focalLength 与 fov 一起写,保证两者自洽。 */
function templateCamera(input: { name: string; position: DirectorCamera["transform"]["position"]; target: DirectorCamera["target"]; focalLength: number; focusDistance: number; aperture: number }): DirectorCamera {
const base = createDirectorCamera(input.name);
return {
...base,
transform: { ...base.transform, position: input.position },
target: input.target,
focalLength: input.focalLength,
fov: directorFocalLengthToFov(input.focalLength),
aperture: input.aperture,
focusDistance: input.focusDistance,
};
}
/** 演员朝向:绕 Y 轴。左侧演员朝右为 +PI/2,右侧演员朝左为 -PI/2。 */
function facing(actor: DirectorObject, yaw: number): DirectorObject {
return { ...actor, transform: { ...actor.transform, rotation: [0, yaw, 0] } };
}
function buildBlueprint(id: DirectorTemplateId): TemplateBlueprint {
if (id === "monologue") {
return {
objects: [createDirectorActor("演员 1", [0, 0, 0], DIRECTOR_ACTOR_COLORS[0])],
camera: templateCamera({ name: "主摄影机", position: [1.5, 1.65, 3.1], target: [0, 1.15, 0], focalLength: 50, focusDistance: 3.4, aperture: 2.2 }),
shot: { name: "镜头 1", duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" },
};
}
if (id === "dialogue") {
// dx = 1.6 >= (0.8 + 0.8) / 2 + 0.25 = 1.05,两名演员必然不重叠。
return {
objects: [facing(createDirectorActor("演员 1", [-0.8, 0, 0.3], DIRECTOR_ACTOR_COLORS[0]), Math.PI / 2), facing(createDirectorActor("演员 2", [0.8, 0, -0.3], DIRECTOR_ACTOR_COLORS[2]), -Math.PI / 2)],
camera: templateCamera({ name: "主摄影机", position: [2.9, 1.7, 3.4], target: [0, 1.1, 0], focalLength: 40, focusDistance: 4.2, aperture: 2.8 }),
shot: { name: "镜头 1", duration: 6, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" },
};
}
if (id === "blocking") {
// 三名演员两两 dx >= 1.8,全部大于 1.05 阈值。
return {
objects: [createDirectorActor("演员 1", [-1.8, 0, 0.6], DIRECTOR_ACTOR_COLORS[0]), createDirectorActor("演员 2", [0, 0, -0.4], DIRECTOR_ACTOR_COLORS[2]), createDirectorActor("演员 3", [1.8, 0, 0.8], DIRECTOR_ACTOR_COLORS[3])],
camera: templateCamera({ name: "主摄影机", position: [0.6, 3.1, 7.4], target: [0, 1, 0], focalLength: 28, focusDistance: 7.6, aperture: 4 }),
shot: { name: "镜头 1", duration: 8, fps: 24, shotSize: "wide", cameraMove: "static", prompt: "" },
};
}
if (id === "product") {
// 产品镜头没有演员。背景板靠 dz=1.35 拉开,侧面道具靠 dx=1.15 拉开。
const product = createDirectorObject("box", "产品主体", [0, 0.5, 0], "#b8c0ca");
const backdrop = createDirectorObject("plane", "背景板", [0, 1.4, -1.35], "#e8ebef");
const prop = createDirectorObject("cylinder", "侧面道具", [1.15, 0.4, 0.5], "#8795a5");
return {
objects: [
{ ...product, transform: { ...product.transform, scale: [0.9, 0.9, 0.9] } },
{ ...backdrop, transform: { ...backdrop.transform, scale: [3.2, 2, 1] } },
{ ...prop, transform: { ...prop.transform, scale: [0.5, 0.8, 0.5] } },
],
camera: templateCamera({ name: "主摄影机", position: [1.1, 1.05, 2.35], target: [0, 0.55, 0], focalLength: 85, focusDistance: 2.6, aperture: 2.8 }),
shot: { name: "镜头 1", duration: 4, fps: 24, shotSize: "close_up", cameraMove: "push_in", prompt: "" },
};
}
return {
objects: [],
camera: templateCamera({ name: "主摄影机", position: [4.8, 2.7, 6.8], target: [0, 1, 0], focalLength: 35, focusDistance: 5, aperture: 2.8 }),
shot: { name: "镜头 1", duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" },
};
}
/**
* 按模板生成全新场景。
* 未知 id 回落到空场景 —— 宁可少给内容,也不要偷偷塞回默认演员。
*/
export function createDirectorSceneFromTemplate(templateId: DirectorTemplateId, title = "未命名场景"): DirectorScene {
const blueprint = buildBlueprint(templateId);
const now = new Date().toISOString();
const shotId = nanoid();
return {
id: nanoid(),
version: 1,
title,
background: "#d8dde3",
environmentIntensity: 0.7,
gridVisible: true,
objects: blueprint.objects,
cameras: [blueprint.camera],
lights: threePointRig(),
shots: [{ ...blueprint.shot, id: shotId, cameraId: blueprint.camera.id }],
activeShotId: shotId,
createdAt: now,
updatedAt: now,
};
}

View File

@@ -0,0 +1,362 @@
import { Euler, Vector3 } from "three";
import { interpolateDirectorTransform } from "@/lib/canvas/director/director-scene";
import type { DirectorCamera, DirectorScene, DirectorShot, DirectorTransform, DirectorVec3 } from "@/types/director";
/**
* 视口取景模式(3D / CAM / 五个正交轴向:俯视、正视、背视、左视、右视)。
*
* 与一级模式(director-modes.ts 的摆场/姿态/动画/摄影机)是两个正交的量:
* 一级模式决定「能改什么」,取景模式只决定「从哪只眼睛看」。
*
* viewMode 是纯视口状态,绝不写入 DirectorScene,也绝不产生 undo/history 记录 ——
* 换一只眼睛看场景不是对内容的修改。本模块只做纯函数,不持有任何 three 对象。
*
* 五个正交轴向和 CAM 一样锁环绕,但取景来自场景内容的包围盒而不是某台摄影机:
* resolveDirectorOrthographicFraming 解出带 margin 的投影跨度,
* resolveDirectorOrthographicFrustum 再按视口宽高比收成安全的正交半范围。
*/
export type DirectorViewMode = "free" | "camera" | "top" | "front" | "back" | "left" | "right";
export type DirectorViewModeCapabilities = {
/** 是否允许 OrbitControls 自由环绕。只有 free 允许;CAM 与五个正交轴向都要锁定构图,拖拽会破坏取景。 */
orbit: boolean;
/** 是否由 shot 的摄影机接管取景(视口相机 == 当前镜头)。只有 CAM。 */
framed: boolean;
/** 投影方式。free/camera 是透视;五个正交轴向必须是正交投影,否则物体比例会随远近畸变。 */
projection: "perspective" | "orthographic";
};
const CAPABILITIES: Record<DirectorViewMode, DirectorViewModeCapabilities> = {
// 3D:编辑用的自由视角,可环绕、可缩放,摄影机只被关键帧驱动时才回写。
free: { orbit: true, framed: false, projection: "perspective" },
// CAM:所见即成片取景,视口 == 当前 shot 的摄影机,禁止环绕。
camera: { orbit: false, framed: true, projection: "perspective" },
// 五个正交轴向:取景来自场景包围盒而非某台摄影机,同样锁环绕,投影必须正交。
top: { orbit: false, framed: false, projection: "orthographic" },
front: { orbit: false, framed: false, projection: "orthographic" },
back: { orbit: false, framed: false, projection: "orthographic" },
left: { orbit: false, framed: false, projection: "orthographic" },
right: { orbit: false, framed: false, projection: "orthographic" },
};
export const DIRECTOR_VIEW_MODES: Array<{ mode: DirectorViewMode; label: string; hint: string }> = [
{ mode: "free", label: "3D", hint: "自由视角:拖拽环绕、滚轮缩放" },
{ mode: "camera", label: "CAM", hint: "机位视图:按当前镜头的摄影机取景,环绕已锁定" },
{ mode: "top", label: "TOP", hint: "俯视:从正上方向下看,正交投影,环绕已锁定" },
{ mode: "front", label: "FRONT", hint: "正视:沿 -Z 方向看向场景,正交投影,环绕已锁定" },
{ mode: "back", label: "BACK", hint: "背视:沿 +Z 方向看向场景,正交投影,环绕已锁定" },
{ mode: "left", label: "LEFT", hint: "左视:沿 +X 方向看向场景,正交投影,环绕已锁定" },
{ mode: "right", label: "RIGHT", hint: "右视:沿 -X 方向看向场景,正交投影,环绕已锁定" },
];
export const DIRECTOR_DEFAULT_VIEW_MODE: DirectorViewMode = "free";
/** 未知模式回落默认能力,绝不返回 undefined —— 调用方直接读 .orbit / .framed。 */
export function directorViewModeCapabilities(mode: DirectorViewMode): DirectorViewModeCapabilities {
return CAPABILITIES[mode] ?? CAPABILITIES[DIRECTOR_DEFAULT_VIEW_MODE];
}
/**
* 当前 shot / 摄影机的唯一解析入口。
*
* activeShotId 指向不存在的 shot 时回落到第一个 shot;shot 的 cameraId 指向不存在的
* 摄影机时回落到第一台。空场景返回 null 而不是抛异常 —— 视口在任何场景下都必须能渲染。
*/
export function resolveDirectorActiveShot(scene: DirectorScene): DirectorShot | null {
return scene.shots.find((item) => item.id === scene.activeShotId) || scene.shots[0] || null;
}
export function resolveDirectorActiveCamera(scene: DirectorScene): DirectorCamera | null {
const shot = resolveDirectorActiveShot(scene);
return scene.cameras.find((item) => item.id === shot?.cameraId) || scene.cameras[0] || null;
}
/** 取景数据:CAM 模式下视口相机需要被写入的完整状态。 */
export type DirectorViewFraming = {
cameraId: string;
position: DirectorVec3;
target: DirectorVec3;
/** 世界系 up。保留 roll,且保证与视线不共线。 */
up: DirectorVec3;
fov: number;
near: number;
far: number;
};
// 叉积长度低于此值即视为共线:lookAt 会解出非法基向量(NaN 矩阵),视口直接黑屏。
const DIRECTOR_VIEW_COLLINEAR_EPSILON = 1e-6;
// 退化时的备选 up:世界 up 优先,其次两个水平轴 —— 正俯视/正仰视镜头落在这里。
const DIRECTOR_VIEW_UP_FALLBACKS: DirectorVec3[] = [
[0, 1, 0],
[0, 0, -1],
[1, 0, 0],
];
/**
* 解出带 roll 的 up 向量。
*
* 摄影机 rotation 的 z 分量就是荷兰角。裸 lookAt 会丢掉它 —— three 用固定的 camera.up
* 重算基向量,画面永远水平。这里把世界 up 按摄影机自身欧拉角旋转,roll 才留得住。
*
* 再处理退化:正俯视/正仰视时旋转后的 up 与视线共线,叉积为 0,lookAt 解出 NaN 矩阵。
* 此时按 世界up -> -Z -> +X 取第一个不共线的备选,绝不返回让相机失效的值。
*/
export function resolveDirectorViewUp(rotation: DirectorVec3, viewDirection: DirectorVec3): DirectorVec3 {
const view = new Vector3(...viewDirection);
if (view.lengthSq() <= DIRECTOR_VIEW_COLLINEAR_EPSILON) return [0, 1, 0];
const rolled = new Vector3(0, 1, 0).applyEuler(new Euler(...rotation));
const candidates = rolled.lengthSq() > DIRECTOR_VIEW_COLLINEAR_EPSILON ? [rolled.toArray() as DirectorVec3, ...DIRECTOR_VIEW_UP_FALLBACKS] : DIRECTOR_VIEW_UP_FALLBACKS;
return candidates.find((candidate) => new Vector3(...candidate).cross(view).length() > DIRECTOR_VIEW_COLLINEAR_EPSILON) ?? [0, 1, 0];
}
/**
* Three 透视投影是否可写进 PerspectiveCamera。
*
* 只检查有限还不够:fov=0 / 180、near<=0、far<=near 都能通过 isFinite,
* 但 makePerspective 会得到 Inf/NaN 矩阵,视口黑屏。fov 必须落在 (0, 180)。
*/
export function directorUsablePerspectiveProjection(input: { fov: number; near: number; far: number }): boolean {
const { fov, near, far } = input;
if (![fov, near, far].every(Number.isFinite)) return false;
if (fov <= 0 || fov >= 180) return false;
if (near <= 0) return false;
return far > near;
}
export type DirectorEffectiveViewport = {
/** 视口应激活的专用相机。CAM 取景失败时必须是 free,不能停在从未写入的 camCamera。 */
camera: "free" | "camera" | "orthographic";
/** 是否允许环绕 free 相机。与 camera 同步:只有 free 为 true。 */
orbit: boolean;
};
/**
* 视口活动相机与交互的唯一决策。
*
* CAM 取景为 null(空场景、非法光学参数)时回落到已有的 free 相机和自由环绕,
* 绝不改 DirectorScene。取景重新合法后确定性回到 CAM(orbit 锁死)。
* 正交轴向不走这条回落:它们始终有安全默认包围盒。
*/
export function resolveDirectorEffectiveViewport(input: { mode: DirectorViewMode; framing: DirectorViewFraming | null }): DirectorEffectiveViewport {
if (directorViewModeCapabilities(input.mode).projection === "orthographic") return { camera: "orthographic", orbit: false };
if (input.mode === "camera" && input.framing) return { camera: "camera", orbit: false };
return { camera: "free", orbit: true };
}
/**
* 解出 CAM 模式下视口相机应有的取景。
*
* 返回 null 的情形都必须走 free 回落:3D 模式、空场景、位置/旋转非法、
* 透视投影不可用(fov/near/far)。绝不把非法值写进 three 相机。
*
* position 取 playhead 上插值后的值,所以 CAM 模式下拖时间轴就是在预览成片运镜。
*/
export function resolveDirectorViewFraming(input: { scene: DirectorScene; mode: DirectorViewMode; playhead: number }): DirectorViewFraming | null {
if (!directorViewModeCapabilities(input.mode).framed) return null;
const camera = resolveDirectorActiveCamera(input.scene);
if (!camera) return null;
const time = Number.isFinite(input.playhead) ? input.playhead : 0;
const transform: DirectorTransform = interpolateDirectorTransform(camera.transform, camera.keyframes, time);
if (![...transform.position, ...transform.rotation, ...camera.target].every(Number.isFinite)) return null;
if (!directorUsablePerspectiveProjection({ fov: camera.fov, near: camera.near, far: camera.far })) return null;
const position = transform.position;
const raw = new Vector3(camera.target[0] - position[0], camera.target[1] - position[1], camera.target[2] - position[2]);
const degenerate = raw.lengthSq() <= DIRECTOR_VIEW_COLLINEAR_EPSILON;
// 位置与焦点重合时视线为零向量,lookAt 无解:沿摄影机自身 -Z 造一个 1m 外的焦点。
const view = degenerate ? new Vector3(0, 0, -1).applyEuler(new Euler(...transform.rotation)) : raw;
const target: DirectorVec3 = degenerate ? [position[0] + view.x, position[1] + view.y, position[2] + view.z] : camera.target;
return { cameraId: camera.id, position, target, up: resolveDirectorViewUp(transform.rotation, view.toArray() as DirectorVec3), fov: camera.fov, near: camera.near, far: camera.far };
}
/**
* 取景同步键。
*
* 相机回写的 effect 只依赖这个字符串:草稿对象每次 render 都是新身份,但取景数值没变
* 就不该重写相机。数值截到 4 位小数,避免浮点噪声引起无意义的重同步。
*/
export function directorViewFramingKey(framing: DirectorViewFraming | null): string {
if (!framing) return "";
return [...framing.position, ...framing.target, ...framing.up, framing.fov, framing.near, framing.far].map((value) => value.toFixed(4)).join("|") + `|${framing.cameraId}`;
}
/**
* 五个正交轴向的视线方向与 up。这五个轴上视线与 up 恒定正交(都是不同的主轴向量),
* 不会出现 CAM 取景那种「roll 导致 up 与视线共线」的退化情形,因此不需要
* resolveDirectorViewUp 那套回落链。
*
* 方向约定(世界系,Y-up):
* - top:看向 -Y(从上往下看),up 取 -Z。
* - front:看向 -Z,up 取 +Y。
* - back:看向 +Z,up 取 +Y —— front 的正对面。
* - left:看向 +X(从场景左侧看向右侧),up 取 +Y。
* - right:看向 -X,up 取 +Y —— left 的正对面。
*/
type DirectorOrthographicAxis = "top" | "front" | "back" | "left" | "right";
/**
* 正交画面上的世界轴。horizontal/vertical 是投影后的屏幕轴,不是视线轴:
* - top:水平 X / 竖直 Z
* - front / back:水平 X / 竖直 Y
* - left / right:水平 Z / 竖直 Y
*/
const DIRECTOR_ORTHOGRAPHIC_AXES: Record<DirectorOrthographicAxis, { view: DirectorVec3; up: DirectorVec3; horizontal: 0 | 1 | 2; vertical: 0 | 1 | 2 }> = {
top: { view: [0, -1, 0], up: [0, 0, -1], horizontal: 0, vertical: 2 },
front: { view: [0, 0, -1], up: [0, 1, 0], horizontal: 0, vertical: 1 },
back: { view: [0, 0, 1], up: [0, 1, 0], horizontal: 0, vertical: 1 },
left: { view: [1, 0, 0], up: [0, 1, 0], horizontal: 2, vertical: 1 },
right: { view: [-1, 0, 0], up: [0, 1, 0], horizontal: 2, vertical: 1 },
};
/** 穷举类型守卫:合法的正交轴向集合在类型层面就是穷举的,调用方无需再查能力表判断。 */
function isDirectorOrthographicAxis(mode: DirectorViewMode): mode is DirectorOrthographicAxis {
return mode === "top" || mode === "front" || mode === "back" || mode === "left" || mode === "right";
}
function directorFinitePoint(point: DirectorVec3): boolean {
return point.every(Number.isFinite);
}
/**
* 取景点云:可见对象的旋转后缩放包围盒角点 + 全部摄影机/灯光的位置。
*
* 只收对象中心会把大比例背景板、长道具等裁掉。资源尚未加载时无法读取真实 geometry,
* transform.scale 是当前场景合同里稳定可用的保守代理;旋转后的八个角点还能覆盖斜放对象。
*/
function collectDirectorFramingPoints(scene: DirectorScene): DirectorVec3[] {
const points: DirectorVec3[] = [];
for (const object of scene.objects) {
const { position, rotation, scale } = object.transform;
if (!object.visible || !directorFinitePoint(position)) continue;
points.push(position);
if (![...rotation, ...scale].every(Number.isFinite)) continue;
const half = scale.map((value) => Math.abs(value) / 2) as DirectorVec3;
const euler = new Euler(...rotation);
for (const x of [-half[0], half[0]]) {
for (const y of [-half[1], half[1]]) {
for (const z of [-half[2], half[2]]) {
const corner = new Vector3(x, y, z).applyEuler(euler).add(new Vector3(...position));
points.push(corner.toArray() as DirectorVec3);
}
}
}
}
for (const camera of scene.cameras) if (directorFinitePoint(camera.transform.position)) points.push(camera.transform.position);
for (const light of scene.lights) if (directorFinitePoint(light.transform.position)) points.push(light.transform.position);
return points;
}
type DirectorFramingBounds = { center: DirectorVec3; extent: DirectorVec3 };
// 空场景 / 全部点位非法时的安全默认包围盒,贴近场景模板的默认布景尺度(见 director-templates.ts 三点布光半径)。
const DIRECTOR_ORTHOGRAPHIC_DEFAULT_BOUNDS: DirectorFramingBounds = { center: [0, 0, 0], extent: [6, 6, 6] };
// 单轴范围下限:单点或共面内容不能把该轴压成 0,否则正交相机等于无限缩放。
const DIRECTOR_ORTHOGRAPHIC_MIN_EXTENT = 2;
// 构图留白:内容贴着画面边缘看不清楚,按包围盒放大这个比例再取景。
const DIRECTOR_ORTHOGRAPHIC_MARGIN = 1.2;
// 相机与目标的距离相对包围盒半径的倍数:太近会让正交相机的机位穿进内容内部。
const DIRECTOR_ORTHOGRAPHIC_DISTANCE_FACTOR = 2;
const DIRECTOR_ORTHOGRAPHIC_NEAR = 0.1;
// far 相对距离额外预留的深度倍数,保证视线方向上的内容不被裁切。
const DIRECTOR_ORTHOGRAPHIC_FAR_MARGIN = 4;
// 视锥换算的跨度下限:0 / 负 / 非有限值都不能当除数或半范围。
const DIRECTOR_ORTHOGRAPHIC_MIN_SPAN = 1e-3;
// 非法宽高比(0 / 负 / NaN / Infinity)回落正方形,避免半范围变成 NaN 或 Infinity。
const DIRECTOR_ORTHOGRAPHIC_DEFAULT_ASPECT = 1;
/** 点云的轴对齐包围盒中心与三轴范围。点云为空时回落安全默认值,每轴范围不低于最小下限。 */
function resolveDirectorFramingBounds(points: DirectorVec3[]): DirectorFramingBounds {
if (points.length === 0) return DIRECTOR_ORTHOGRAPHIC_DEFAULT_BOUNDS;
const min: DirectorVec3 = [Infinity, Infinity, Infinity];
const max: DirectorVec3 = [-Infinity, -Infinity, -Infinity];
for (const point of points) {
for (let axis = 0; axis < 3; axis += 1) {
if (point[axis] < min[axis]) min[axis] = point[axis];
if (point[axis] > max[axis]) max[axis] = point[axis];
}
}
const center = [0, 1, 2].map((axis) => (min[axis] + max[axis]) / 2) as DirectorVec3;
const extent = [0, 1, 2].map((axis) => Math.max(max[axis] - min[axis], DIRECTOR_ORTHOGRAPHIC_MIN_EXTENT)) as DirectorVec3;
return { center, extent };
}
/** 正交轴向取景数据:视口相机需要被写入的完整状态。 */
export type DirectorOrthographicFraming = {
position: DirectorVec3;
target: DirectorVec3;
/** 世界系 up,恒定为主轴向量,绝不与视线共线。 */
up: DirectorVec3;
/** 投影后的水平跨度(已含构图留白)。不得单独按高度×宽高比展开,否则宽内容会被裁切。 */
horizontalSpan: number;
/** 投影后的竖直跨度(已含构图留白)。 */
verticalSpan: number;
near: number;
far: number;
};
/** 正交相机的左右上下半范围。始终对称,且 width/height 等于安全后的宽高比。 */
export type DirectorOrthographicFrustum = {
halfWidth: number;
halfHeight: number;
left: number;
right: number;
top: number;
bottom: number;
};
function directorSafePositive(value: number, fallback: number): number {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
/**
* 把投影跨度与视口宽高比收成正交半范围。
*
* 必须同时装下水平与竖直跨度:只按高度×aspect 算宽度时,宽于画面的内容会被裁掉。
* aspect 为 0 / 负 / NaN / ±Infinity 时回落 1;跨度非法时回落最小正跨度。
* 这是纯函数,不读 scene,也不写 three 相机。
*/
export function resolveDirectorOrthographicFrustum(input: { horizontalSpan: number; verticalSpan: number; aspect: number }): DirectorOrthographicFrustum {
const horizontalSpan = directorSafePositive(input.horizontalSpan, DIRECTOR_ORTHOGRAPHIC_MIN_SPAN);
const verticalSpan = directorSafePositive(input.verticalSpan, DIRECTOR_ORTHOGRAPHIC_MIN_SPAN);
const aspect = directorSafePositive(input.aspect, DIRECTOR_ORTHOGRAPHIC_DEFAULT_ASPECT);
const neededHalfWidth = horizontalSpan / 2;
const neededHalfHeight = verticalSpan / 2;
const halfWidth = Math.max(neededHalfWidth, neededHalfHeight * aspect);
const halfHeight = Math.max(neededHalfHeight, neededHalfWidth / aspect);
return {
halfWidth,
halfHeight,
left: -halfWidth,
right: halfWidth,
top: halfHeight,
bottom: -halfHeight,
};
}
/**
* 解出五个正交轴向的取景。free/camera 不是正交轴向,返回 null。
*
* target 与两条投影跨度来自可见对象的变换包围盒 + 全部摄影机/灯光的位置包围盒;空场景或全部
* 位置非法(NaN/Infinity)时回落安全默认值,绝不返回会让正交相机退化成无限缩放的 0 值。
* position 由 target 沿该轴向的视线方向反推,因此永远落在包围盒之外,不会穿模。
*/
export function resolveDirectorOrthographicFraming(input: { scene: DirectorScene; mode: DirectorViewMode }): DirectorOrthographicFraming | null {
if (!isDirectorOrthographicAxis(input.mode)) return null;
const axis = DIRECTOR_ORTHOGRAPHIC_AXES[input.mode];
const bounds = resolveDirectorFramingBounds(collectDirectorFramingPoints(input.scene));
const radius = Math.max(...bounds.extent) / 2;
const distance = Math.max(radius * DIRECTOR_ORTHOGRAPHIC_DISTANCE_FACTOR, DIRECTOR_ORTHOGRAPHIC_MIN_EXTENT);
const target = bounds.center;
const position: DirectorVec3 = [target[0] - axis.view[0] * distance, target[1] - axis.view[1] * distance, target[2] - axis.view[2] * distance];
return {
position,
target,
up: axis.up,
horizontalSpan: bounds.extent[axis.horizontal] * DIRECTOR_ORTHOGRAPHIC_MARGIN,
verticalSpan: bounds.extent[axis.vertical] * DIRECTOR_ORTHOGRAPHIC_MARGIN,
near: DIRECTOR_ORTHOGRAPHIC_NEAR,
far: distance + radius * DIRECTOR_ORTHOGRAPHIC_FAR_MARGIN,
};
}