refactor(canvas): 预演台 - 重构工作台与场景交互链路
This commit is contained in:
1 parent
7490860341
commit
8bc93a415b
128 files changed
+42372
-5011
No files matched your search
@@ -79,9 +79,9 @@ export function isolateCopiedNodeMetadata(node: CanvasNodeData, idMap: ReadonlyM
|
||||
.filter((nodeId): nodeId is string => Boolean(nodeId));
|
||||
metadata.videoStartFrameNodeId = remapReferenceId(node.metadata?.videoStartFrameNodeId, idMap);
|
||||
metadata.videoEndFrameNodeId = remapReferenceId(node.metadata?.videoEndFrameNodeId, idMap);
|
||||
metadata.directorPreviewNodeId = remapOwnedNodeId(node.metadata?.directorPreviewNodeId, idMap);
|
||||
metadata.directorDepthNodeId = remapOwnedNodeId(node.metadata?.directorDepthNodeId, idMap);
|
||||
metadata.directorNormalNodeId = remapOwnedNodeId(node.metadata?.directorNormalNodeId, idMap);
|
||||
metadata.previsPreviewNodeId = remapOwnedNodeId(node.metadata?.previsPreviewNodeId, idMap);
|
||||
metadata.previsDepthNodeId = remapOwnedNodeId(node.metadata?.previsDepthNodeId, idMap);
|
||||
metadata.previsNormalNodeId = remapOwnedNodeId(node.metadata?.previsNormalNodeId, idMap);
|
||||
|
||||
const characterViewNodeIds = node.metadata?.characterViewNodeIds;
|
||||
const copiedCharacterViewNodeIds = characterViewNodeIds ? {
|
||||
|
||||
@@ -33,6 +33,30 @@ export async function loadFFmpeg(onProgress?: (progress: MergeVideoProgress) =>
|
||||
}
|
||||
}
|
||||
|
||||
export async function transcodeVideoToMp4(input: Blob, onProgress?: (progress: MergeVideoProgress) => void) {
|
||||
const ffmpeg = await loadFFmpeg(onProgress);
|
||||
const inputName = input.type === "video/mp4" ? "recording.mp4" : "recording.webm";
|
||||
const outputName = "recording-transcoded.mp4";
|
||||
try {
|
||||
await ffmpeg.writeFile(inputName, await fetchFile(input));
|
||||
onProgress?.({ phase: "encoding", progress: 55 });
|
||||
const exitCode = await ffmpeg.exec([
|
||||
"-i", inputName,
|
||||
"-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-movflags", "+faststart",
|
||||
outputName,
|
||||
]);
|
||||
if (exitCode !== 0) throw new Error("视频转码失败,请重试");
|
||||
const output = await ffmpeg.readFile(outputName);
|
||||
onProgress?.({ phase: "encoding", progress: 100 });
|
||||
return new Blob([output as BlobPart], { type: "video/mp4" });
|
||||
} finally {
|
||||
await Promise.all([inputName, outputName].map((file) => ffmpeg.deleteFile(file).catch(() => undefined)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function mergeVideos(inputs: MergeVideoInput[], onProgress?: (progress: MergeVideoProgress) => void) {
|
||||
if (inputs.length < 2) throw new Error("至少选择 2 个视频才能合并");
|
||||
const ffmpeg = await loadFFmpeg(onProgress);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { DirectorScene, DirectorShot } from "../../../types/director";
|
||||
|
||||
/** 预览来源的判定结果。image 之外都不渲染 <img>。 */
|
||||
export type DirectorPreviewSource = { kind: "image"; url: string } | { kind: "loading" } | { kind: "empty" };
|
||||
|
||||
/** 读取画布节点正文;节点已删除或无正文时返回空。 */
|
||||
export type DirectorNodeContentReader = (nodeId: string | undefined) => string | undefined;
|
||||
|
||||
function usableContent(read: DirectorNodeContentReader, nodeId: string | undefined) {
|
||||
if (!nodeId) return null;
|
||||
const content = read(nodeId);
|
||||
if (!content) return null;
|
||||
// 节点被删除、正文缺失或只有空白都必须安全落空;
|
||||
// 有效正文返回 trim 后的值,避免首尾空白拼进 <img src> 造成畸形请求。
|
||||
const trimmed = content.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/** 取当前镜头:优先 metadata 指定,其次场景第一个。 */
|
||||
export function resolveDirectorActiveShot(scene: DirectorScene | null, shotId: string | undefined): DirectorShot | undefined {
|
||||
if (!scene) return undefined;
|
||||
return scene.shots?.find((item) => item.id === shotId) || scene.shots?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览来源优先级(严格自上而下):
|
||||
* 1. node.metadata.directorPreviewNodeId 指向的画布节点正文
|
||||
* 2. 当前 DirectorShot.previewNodeId 指向的画布节点正文(补 metadata 丢失)
|
||||
* 3. scene 为 null:场景尚在准备(loading)
|
||||
* 4. 其余:诚实空态
|
||||
* 不改动任何 scene/node 状态,只做读取判定。
|
||||
*/
|
||||
export function resolveDirectorPreviewSource(input: { scene: DirectorScene | null; shot: DirectorShot | undefined; previewNodeId: string | undefined; readNodeContent: DirectorNodeContentReader }): DirectorPreviewSource {
|
||||
const fromMetadata = usableContent(input.readNodeContent, input.previewNodeId);
|
||||
if (fromMetadata) return { kind: "image", url: fromMetadata };
|
||||
const fromShot = usableContent(input.readNodeContent, input.shot?.previewNodeId);
|
||||
if (fromShot) return { kind: "image", url: fromShot };
|
||||
if (!input.scene) return { kind: "loading" };
|
||||
return { kind: "empty" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载失败的门控:记录「失败的那个 URL」而不是布尔标记。
|
||||
* 语义边界:新给出的坏 URL 仍会先渲染一次,浏览器随后才发出 onError —— 无法避免这一帧。
|
||||
* 本门控保证的是:同一个已失败的 URL 不会被反复渲染,且换成另一个 URL 时自动重试,
|
||||
* 无需外部手动清理标记。
|
||||
*/
|
||||
export function gateDirectorPreviewFailure(source: DirectorPreviewSource, failedUrl: string | null): DirectorPreviewSource {
|
||||
if (source.kind !== "image" || !failedUrl) return source;
|
||||
return source.url === failedUrl ? { kind: "empty" } : source;
|
||||
}
|
||||
+49
-49
@@ -1,23 +1,23 @@
|
||||
import { Euler, Quaternion } from "three";
|
||||
|
||||
import type { DirectorBoneKeyframe, DirectorCamera, DirectorKeyframe, DirectorQuat, DirectorTransform, DirectorVec3 } from "../../../types/director";
|
||||
import { interpolateDirectorBoneRotation, interpolateDirectorTransform, upsertDirectorKeyframe } from "./director-scene";
|
||||
import type { PrevisBoneKeyframe, PrevisCamera, PrevisKeyframe, PrevisQuat, PrevisTransform, PrevisVec3 } from "../../../types/previs";
|
||||
import { interpolatePrevisBoneRotation, interpolatePrevisTransform, upsertPrevisKeyframe } from "./previs-scene";
|
||||
|
||||
// 缩放为 0 时无法用比例表达增量,改用绝对偏移;阈值同时兼顾数值噪声。
|
||||
const SCALE_EPSILON = 1e-6;
|
||||
|
||||
export type DirectorTransformDelta = {
|
||||
position: DirectorVec3;
|
||||
rotation: DirectorQuat;
|
||||
scaleRatio: DirectorVec3;
|
||||
scaleOffset: DirectorVec3;
|
||||
export type PrevisTransformDelta = {
|
||||
position: PrevisVec3;
|
||||
rotation: PrevisQuat;
|
||||
scaleRatio: PrevisVec3;
|
||||
scaleOffset: PrevisVec3;
|
||||
};
|
||||
|
||||
export function directorTransformDelta(from: DirectorTransform, to: DirectorTransform): DirectorTransformDelta {
|
||||
export function previsTransformDelta(from: PrevisTransform, to: PrevisTransform): PrevisTransformDelta {
|
||||
const fromQuaternion = new Quaternion().setFromEuler(new Euler(...from.rotation));
|
||||
const toQuaternion = new Quaternion().setFromEuler(new Euler(...to.rotation));
|
||||
const scaleRatio: DirectorVec3 = [1, 1, 1];
|
||||
const scaleOffset: DirectorVec3 = [0, 0, 0];
|
||||
const scaleRatio: PrevisVec3 = [1, 1, 1];
|
||||
const scaleOffset: PrevisVec3 = [0, 0, 0];
|
||||
from.scale.forEach((value, index) => {
|
||||
if (Math.abs(value) > SCALE_EPSILON) {
|
||||
scaleRatio[index] = to.scale[index] / value;
|
||||
@@ -27,29 +27,29 @@ export function directorTransformDelta(from: DirectorTransform, to: DirectorTran
|
||||
});
|
||||
return {
|
||||
position: [to.position[0] - from.position[0], to.position[1] - from.position[1], to.position[2] - from.position[2]],
|
||||
rotation: toQuaternion.multiply(fromQuaternion.invert()).toArray() as DirectorQuat,
|
||||
rotation: toQuaternion.multiply(fromQuaternion.invert()).toArray() as PrevisQuat,
|
||||
scaleRatio,
|
||||
scaleOffset,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDirectorTransformDelta(transform: DirectorTransform, delta: DirectorTransformDelta): DirectorTransform {
|
||||
export function applyPrevisTransformDelta(transform: PrevisTransform, delta: PrevisTransformDelta): PrevisTransform {
|
||||
const rotated = new Quaternion(...delta.rotation).multiply(new Quaternion().setFromEuler(new Euler(...transform.rotation)));
|
||||
return {
|
||||
position: transform.position.map((value, index) => value + delta.position[index]) as DirectorVec3,
|
||||
rotation: new Euler().setFromQuaternion(rotated).toArray().slice(0, 3) as DirectorVec3,
|
||||
scale: transform.scale.map((value, index) => value * delta.scaleRatio[index] + delta.scaleOffset[index]) as DirectorVec3,
|
||||
position: transform.position.map((value, index) => value + delta.position[index]) as PrevisVec3,
|
||||
rotation: new Euler().setFromQuaternion(rotated).toArray().slice(0, 3) as PrevisVec3,
|
||||
scale: transform.scale.map((value, index) => value * delta.scaleRatio[index] + delta.scaleOffset[index]) as PrevisVec3,
|
||||
};
|
||||
}
|
||||
|
||||
export function snapDirectorTime(time: number, fps: number) {
|
||||
export function snapPrevisTime(time: number, fps: number) {
|
||||
const safeFps = fps > 0 ? fps : 24;
|
||||
return Math.max(0, Math.round(time * safeFps) / safeFps);
|
||||
}
|
||||
|
||||
export type DirectorObjectTransformEdit = {
|
||||
transform: DirectorTransform;
|
||||
keyframes: DirectorKeyframe[];
|
||||
export type PrevisObjectTransformEdit = {
|
||||
transform: PrevisTransform;
|
||||
keyframes: PrevisKeyframe[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -58,28 +58,28 @@ export type DirectorObjectTransformEdit = {
|
||||
* autoKey 关闭:把「渲染值 -> 编辑值」的增量整体搬到 base 和所有已有关键帧,
|
||||
* 使编辑在存在关键帧时依然可见,且不改变未被编辑场景的既有渲染结果。
|
||||
*/
|
||||
export function resolveDirectorObjectTransformEdit(input: { base: DirectorTransform; keyframes: DirectorKeyframe[]; rendered: DirectorTransform; edited: DirectorTransform; autoKey: boolean; time: number }): DirectorObjectTransformEdit {
|
||||
export function resolvePrevisObjectTransformEdit(input: { base: PrevisTransform; keyframes: PrevisKeyframe[]; rendered: PrevisTransform; edited: PrevisTransform; autoKey: boolean; time: number }): PrevisObjectTransformEdit {
|
||||
const { base, keyframes, rendered, edited, autoKey, time } = input;
|
||||
if (autoKey) return { transform: base, keyframes: upsertDirectorKeyframe(keyframes, time, edited) };
|
||||
if (autoKey) return { transform: base, keyframes: upsertPrevisKeyframe(keyframes, time, edited) };
|
||||
if (!keyframes.length) return { transform: edited, keyframes };
|
||||
const delta = directorTransformDelta(rendered, edited);
|
||||
const delta = previsTransformDelta(rendered, edited);
|
||||
return {
|
||||
transform: applyDirectorTransformDelta(base, delta),
|
||||
keyframes: keyframes.map((keyframe) => ({ ...keyframe, transform: applyDirectorTransformDelta(keyframe.transform, delta) })),
|
||||
transform: applyPrevisTransformDelta(base, delta),
|
||||
keyframes: keyframes.map((keyframe) => ({ ...keyframe, transform: applyPrevisTransformDelta(keyframe.transform, delta) })),
|
||||
};
|
||||
}
|
||||
|
||||
export type DirectorBoneLayerInput = {
|
||||
export type PrevisBoneLayerInput = {
|
||||
/** 有动作片段时由 mixer 求值的当前旋转;无动作时为空。 */
|
||||
motion?: DirectorQuat | null;
|
||||
motion?: PrevisQuat | null;
|
||||
/** 静默姿势下的骨骼静置旋转。 */
|
||||
rest?: DirectorQuat | null;
|
||||
rest?: PrevisQuat | null;
|
||||
/** 姿势预设相对静置的增量。 */
|
||||
poseDelta?: DirectorQuat | null;
|
||||
poseDelta?: PrevisQuat | null;
|
||||
/** 静态骨骼覆盖值。 */
|
||||
override?: DirectorQuat | null;
|
||||
override?: PrevisQuat | null;
|
||||
/** 骨骼关键帧轨道。 */
|
||||
keyframes?: DirectorBoneKeyframe[] | null;
|
||||
keyframes?: PrevisBoneKeyframe[] | null;
|
||||
time: number;
|
||||
};
|
||||
|
||||
@@ -87,27 +87,27 @@ export type DirectorBoneLayerInput = {
|
||||
* 骨骼求值优先级(低到高):静置/姿势或动作片段 -> 静态覆盖 -> 骨骼关键帧。
|
||||
* 手指骨骼与主干骨骼共用同一规则。
|
||||
*/
|
||||
export function resolveDirectorBoneRotation(input: DirectorBoneLayerInput): DirectorQuat | null {
|
||||
const motionOrRest = input.motion ? input.motion : input.rest ? (input.poseDelta ? (new Quaternion(...input.rest).multiply(new Quaternion(...input.poseDelta)).toArray() as DirectorQuat) : input.rest) : null;
|
||||
export function resolvePrevisBoneRotation(input: PrevisBoneLayerInput): PrevisQuat | null {
|
||||
const motionOrRest = input.motion ? input.motion : input.rest ? (input.poseDelta ? (new Quaternion(...input.rest).multiply(new Quaternion(...input.poseDelta)).toArray() as PrevisQuat) : input.rest) : null;
|
||||
const staged = input.override || motionOrRest;
|
||||
const keyframes = input.keyframes || [];
|
||||
if (!keyframes.length) return input.override || null;
|
||||
if (!staged) return interpolateDirectorBoneRotation([0, 0, 0, 1], keyframes, input.time);
|
||||
return interpolateDirectorBoneRotation(staged, keyframes, input.time);
|
||||
if (!staged) return interpolatePrevisBoneRotation([0, 0, 0, 1], keyframes, input.time);
|
||||
return interpolatePrevisBoneRotation(staged, keyframes, input.time);
|
||||
}
|
||||
|
||||
export type DirectorGestureState = { active: boolean; committed: boolean; transforming: boolean };
|
||||
export type DirectorGestureEvent = "start" | "commit" | "cancel";
|
||||
export type PrevisGestureState = { active: boolean; committed: boolean; transforming: boolean };
|
||||
export type PrevisGestureEvent = "start" | "commit" | "cancel";
|
||||
|
||||
export const directorGestureIdle: DirectorGestureState = { active: false, committed: false, transforming: false };
|
||||
export const previsGestureIdle: PrevisGestureState = { active: false, committed: false, transforming: false };
|
||||
|
||||
/**
|
||||
* 一次手势只允许一个终态:commit 恰好提交一次,任何终态都必须清掉 transforming,
|
||||
* 否则 OrbitControls 会一直停留在禁用状态。
|
||||
*/
|
||||
export function reduceDirectorGesture(state: DirectorGestureState, event: DirectorGestureEvent): DirectorGestureState {
|
||||
export function reducePrevisGesture(state: PrevisGestureState, event: PrevisGestureEvent): PrevisGestureState {
|
||||
if (event === "start") return { active: true, committed: false, transforming: true };
|
||||
if (!state.active) return { ...directorGestureIdle, committed: false };
|
||||
if (!state.active) return { ...previsGestureIdle, committed: false };
|
||||
if (event === "commit") return { active: false, committed: true, transforming: false };
|
||||
return { active: false, committed: false, transforming: false };
|
||||
}
|
||||
@@ -116,9 +116,9 @@ export function reduceDirectorGesture(state: DirectorGestureState, event: Direct
|
||||
* 记录关键帧:取值时间与写入时间是两个不同的量。
|
||||
* 取值用 raw playhead(视口真正渲染的时间),写入用 snapped 目的时间。
|
||||
*/
|
||||
export function resolveDirectorKeyframeRecord(input: { base: DirectorTransform; keyframes: DirectorKeyframe[]; rawTime: number; snappedTime: number }) {
|
||||
const rendered = interpolateDirectorTransform(input.base, input.keyframes, input.rawTime);
|
||||
return { time: input.snappedTime, transform: rendered, keyframes: upsertDirectorKeyframe(input.keyframes, input.snappedTime, rendered) };
|
||||
export function resolvePrevisKeyframeRecord(input: { base: PrevisTransform; keyframes: PrevisKeyframe[]; rawTime: number; snappedTime: number }) {
|
||||
const rendered = interpolatePrevisTransform(input.base, input.keyframes, input.rawTime);
|
||||
return { time: input.snappedTime, transform: rendered, keyframes: upsertPrevisKeyframe(input.keyframes, input.snappedTime, rendered) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,19 +127,19 @@ export function resolveDirectorKeyframeRecord(input: { base: DirectorTransform;
|
||||
* 没有动画轨道时更新基础 transform;已有轨道时写当前播放头关键帧,否则旧关键帧会
|
||||
* 继续接管 CAM 取景,让界面提示“已对齐”但画面立即跳回旧位置。
|
||||
*/
|
||||
export function resolveDirectorCameraAlignment(camera: DirectorCamera, transform: DirectorTransform, time: number): DirectorCamera {
|
||||
export function resolvePrevisCameraAlignment(camera: PrevisCamera, transform: PrevisTransform, time: number): PrevisCamera {
|
||||
if (!camera.keyframes.length) return { ...camera, transform };
|
||||
return { ...camera, keyframes: upsertDirectorKeyframe(camera.keyframes, time, transform) };
|
||||
return { ...camera, keyframes: upsertPrevisKeyframe(camera.keyframes, time, transform) };
|
||||
}
|
||||
|
||||
/** 生成运镜只更新首尾帧;保留用户手工添加的中间帧、帧 id 与 easing。 */
|
||||
export function resolveDirectorCameraMoveKeyframes(keyframes: DirectorKeyframe[], start: DirectorTransform, end: DirectorTransform, duration: number) {
|
||||
export function resolvePrevisCameraMoveKeyframes(keyframes: PrevisKeyframe[], start: PrevisTransform, end: PrevisTransform, duration: number) {
|
||||
const endTime = Number.isFinite(duration) && duration > 0 ? duration : 0;
|
||||
return upsertDirectorKeyframe(upsertDirectorKeyframe(keyframes, 0, start), endTime, end);
|
||||
return upsertPrevisKeyframe(upsertPrevisKeyframe(keyframes, 0, start), endTime, end);
|
||||
}
|
||||
|
||||
/** 播放头按镜头时长循环;非法输入回落 0,长帧也保留越界余量。 */
|
||||
export function advanceDirectorPlayhead(playhead: number, elapsed: number, duration: number) {
|
||||
export function advancePrevisPlayhead(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;
|
||||
@@ -151,11 +151,11 @@ export function advanceDirectorPlayhead(playhead: number, elapsed: number, durat
|
||||
* key 必须包含该 playhead 上真正解算出的 transform,否则「同一 playhead 改关键帧内容」
|
||||
* 不会引起重新同步。
|
||||
*/
|
||||
export function directorCameraSyncKey(input: { camera?: { id: string; transform: DirectorTransform; target: DirectorVec3; fov: number; near: number; far: number; keyframes: DirectorKeyframe[] } | null; playhead: number; playing: boolean }) {
|
||||
export function previsCameraSyncKey(input: { camera?: { id: string; transform: PrevisTransform; target: PrevisVec3; fov: number; near: number; far: number; keyframes: PrevisKeyframe[] } | null; playhead: number; playing: boolean }) {
|
||||
const { camera, playhead, playing } = input;
|
||||
if (!camera) return null;
|
||||
const animated = playing || camera.keyframes.length > 0;
|
||||
const resolved = interpolateDirectorTransform(camera.transform, camera.keyframes, playhead);
|
||||
const resolved = interpolatePrevisTransform(camera.transform, camera.keyframes, playhead);
|
||||
const statics = [camera.id, ...resolved.position, ...resolved.rotation, ...resolved.scale, ...camera.target, camera.fov, camera.near, camera.far].join(":");
|
||||
return animated ? `${statics}@${playhead.toFixed(4)}` : statics;
|
||||
}
|
||||
+12
-3
@@ -1,10 +1,10 @@
|
||||
import { Mesh, MeshStandardMaterial, Scene, type Material } from "three";
|
||||
import { Mesh, MeshStandardMaterial, Object3D, Scene, type Material } from "three";
|
||||
|
||||
/**
|
||||
* 将场景整体切换为白膜材质,返回恢复函数;恢复时销毁共享的 clay 材质。
|
||||
*
|
||||
* 两类 mesh 必须跳过,否则切换本身会破坏渲染:
|
||||
* - directorActor mesh 的材质由展示层按展示状态自行管理;
|
||||
* - previsActor mesh 的材质由展示层按展示状态自行管理;
|
||||
* - 带 uniforms 的 ShaderMaterial(drei Grid/Line 等)由组件自身 useFrame 逐帧读写 uniforms,
|
||||
* 换成 clay 后渲染循环会逐帧抛错、画布冻结,白膜录制只剩启动前手动渲染的首帧。
|
||||
*/
|
||||
@@ -13,7 +13,7 @@ export function applyClaySceneMaterials(scene: Scene) {
|
||||
const originals: Array<{ mesh: Mesh; material: Material | Material[] }> = [];
|
||||
scene.traverse((child) => {
|
||||
const mesh = child as Mesh;
|
||||
if (!mesh.isMesh || mesh.userData.directorActor) return;
|
||||
if (!mesh.isMesh || hasPrevisActorAncestor(mesh)) return;
|
||||
const material = mesh.material;
|
||||
const shaderBacked = Array.isArray(material) ? material.some((item) => "uniforms" in item) : "uniforms" in material;
|
||||
if (shaderBacked) return;
|
||||
@@ -27,3 +27,12 @@ export function applyClaySceneMaterials(scene: Scene) {
|
||||
clayMaterial.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
function hasPrevisActorAncestor(object: Object3D) {
|
||||
let current: Object3D | null = object;
|
||||
while (current) {
|
||||
if (current.userData.previsActor) return true;
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
+8
-8
@@ -1,9 +1,9 @@
|
||||
import { recordDiagnosticEvent } from "@/services/diagnostics/client-diagnostics";
|
||||
|
||||
import { formatDirectorDiagnosticCode, projectDirectorDiagnostic, type DirectorDiagnosticCode, type DirectorDiagnosticFields } from "@/lib/canvas/director/director-diagnostics";
|
||||
import { formatPrevisDiagnosticCode, projectPrevisDiagnostic, type PrevisDiagnosticCode, type PrevisDiagnosticFields } from "@/lib/canvas/previs/previs-diagnostics";
|
||||
|
||||
/**
|
||||
* 导演台故障事件的唯一记录入口。
|
||||
* 预演台故障事件的唯一记录入口。
|
||||
*
|
||||
* 复用统一的 client-diagnostics 缓冲区,绝不另建平行日志。
|
||||
* 只传固定 message 与安全 code/字段;stack 一律不填。
|
||||
@@ -18,19 +18,19 @@ function now(): number {
|
||||
}
|
||||
|
||||
/** 测试与页面重置用:清空去重状态。 */
|
||||
export function resetDirectorDiagnosticDedupe(): void {
|
||||
export function resetPrevisDiagnosticDedupe(): void {
|
||||
lastSeen.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一条导演台诊断事件。
|
||||
* 记录一条预演台诊断事件。
|
||||
* 返回是否真的写入(未知 code 或命中去重窗口时返回 false),便于测试断言。
|
||||
*/
|
||||
export function recordDirectorDiagnostic(code: DirectorDiagnosticCode, fields: DirectorDiagnosticFields = {}): boolean {
|
||||
const event = projectDirectorDiagnostic(code, fields);
|
||||
export function recordPrevisDiagnostic(code: PrevisDiagnosticCode, fields: PrevisDiagnosticFields = {}): boolean {
|
||||
const event = projectPrevisDiagnostic(code, fields);
|
||||
if (!event) return false;
|
||||
|
||||
const signature = formatDirectorDiagnosticCode(event);
|
||||
const signature = formatPrevisDiagnosticCode(event);
|
||||
const timestamp = now();
|
||||
const previous = lastSeen.get(signature);
|
||||
if (previous !== undefined && timestamp - previous < DEDUPE_WINDOW_MS) return false;
|
||||
@@ -40,7 +40,7 @@ export function recordDirectorDiagnostic(code: DirectorDiagnosticCode, fields: D
|
||||
level: event.level,
|
||||
category: "runtime",
|
||||
// code 携带 scene/object/枚举后缀,便于在统一诊断流里检索而无需自由文本。
|
||||
// 不写 canvasId:DirectorScene.id 不是 canvas id,冒充会污染既有后端契约的语义。
|
||||
// 不写 canvasId:PrevisScene.id 不是 canvas id,冒充会污染既有后端契约的语义。
|
||||
code: signature,
|
||||
message: event.message,
|
||||
});
|
||||
+63
-63
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 导演台诊断领域:稳定白名单 code + 安全结构化字段。
|
||||
* 预演台诊断领域:稳定白名单 code + 安全结构化字段。
|
||||
*
|
||||
* 调用边界的硬约束(安全边界,不是风格偏好):
|
||||
* - 绝不接收 Error / stack / componentStack / URL / Cookie / Authorization / token / 密钥;
|
||||
@@ -10,74 +10,74 @@
|
||||
*/
|
||||
|
||||
/** 故障与恢复代码白名单。新增代码必须同时补 message 与测试。 */
|
||||
export const DIRECTOR_DIAGNOSTIC_CODES = [
|
||||
"DIRECTOR_VIEWPORT_RENDER_FAILED",
|
||||
"DIRECTOR_VIEWPORT_CONTEXT_LOST",
|
||||
"DIRECTOR_VIEWPORT_CONTEXT_RESTORED",
|
||||
"DIRECTOR_MODEL_LOAD_FAILED",
|
||||
"DIRECTOR_MODEL_LOAD_RETRY",
|
||||
"DIRECTOR_MODEL_ADOPT_FAILED",
|
||||
"DIRECTOR_SAVE_FLUSH_FAILED",
|
||||
"DIRECTOR_SAVE_RETRY_FAILED",
|
||||
"DIRECTOR_SAVE_RETRY_RECOVERED",
|
||||
"DIRECTOR_SAVE_DRAFT_UNAVAILABLE",
|
||||
"DIRECTOR_CLOSE_BLOCKED",
|
||||
export const PREVIS_DIAGNOSTIC_CODES = [
|
||||
"PREVIS_VIEWPORT_RENDER_FAILED",
|
||||
"PREVIS_VIEWPORT_CONTEXT_LOST",
|
||||
"PREVIS_VIEWPORT_CONTEXT_RESTORED",
|
||||
"PREVIS_MODEL_LOAD_FAILED",
|
||||
"PREVIS_MODEL_LOAD_RETRY",
|
||||
"PREVIS_MODEL_ADOPT_FAILED",
|
||||
"PREVIS_SAVE_FLUSH_FAILED",
|
||||
"PREVIS_SAVE_RETRY_FAILED",
|
||||
"PREVIS_SAVE_RETRY_RECOVERED",
|
||||
"PREVIS_SAVE_DRAFT_UNAVAILABLE",
|
||||
"PREVIS_CLOSE_BLOCKED",
|
||||
] as const;
|
||||
|
||||
export type DirectorDiagnosticCode = (typeof DIRECTOR_DIAGNOSTIC_CODES)[number];
|
||||
export type PrevisDiagnosticCode = (typeof PREVIS_DIAGNOSTIC_CODES)[number];
|
||||
|
||||
const CODE_SET: ReadonlySet<string> = new Set(DIRECTOR_DIAGNOSTIC_CODES);
|
||||
const CODE_SET: ReadonlySet<string> = new Set(PREVIS_DIAGNOSTIC_CODES);
|
||||
|
||||
/** 固定文案:调用方不得传入任意字符串,避免业务正文经 message 泄漏。 */
|
||||
const MESSAGES: Record<DirectorDiagnosticCode, string> = {
|
||||
DIRECTOR_VIEWPORT_RENDER_FAILED: "导演台 3D 视口渲染失败",
|
||||
DIRECTOR_VIEWPORT_CONTEXT_LOST: "导演台 WebGL 上下文丢失",
|
||||
DIRECTOR_VIEWPORT_CONTEXT_RESTORED: "导演台 WebGL 上下文已恢复",
|
||||
DIRECTOR_MODEL_LOAD_FAILED: "导演台 3D 模型加载失败",
|
||||
DIRECTOR_MODEL_LOAD_RETRY: "导演台 3D 模型重试加载",
|
||||
DIRECTOR_MODEL_ADOPT_FAILED: "导演台 3D 模型采纳失败",
|
||||
DIRECTOR_SAVE_FLUSH_FAILED: "导演台场景保存失败",
|
||||
DIRECTOR_SAVE_RETRY_FAILED: "导演台场景重试保存仍失败",
|
||||
DIRECTOR_SAVE_RETRY_RECOVERED: "导演台场景重试保存成功",
|
||||
DIRECTOR_SAVE_DRAFT_UNAVAILABLE: "导演台本地草稿不可用",
|
||||
DIRECTOR_CLOSE_BLOCKED: "导演台关闭被阻止",
|
||||
const MESSAGES: Record<PrevisDiagnosticCode, string> = {
|
||||
PREVIS_VIEWPORT_RENDER_FAILED: "预演台 3D 视口渲染失败",
|
||||
PREVIS_VIEWPORT_CONTEXT_LOST: "预演台 WebGL 上下文丢失",
|
||||
PREVIS_VIEWPORT_CONTEXT_RESTORED: "预演台 WebGL 上下文已恢复",
|
||||
PREVIS_MODEL_LOAD_FAILED: "预演台 3D 模型加载失败",
|
||||
PREVIS_MODEL_LOAD_RETRY: "预演台 3D 模型重试加载",
|
||||
PREVIS_MODEL_ADOPT_FAILED: "预演台 3D 模型采纳失败",
|
||||
PREVIS_SAVE_FLUSH_FAILED: "预演台场景保存失败",
|
||||
PREVIS_SAVE_RETRY_FAILED: "预演台场景重试保存仍失败",
|
||||
PREVIS_SAVE_RETRY_RECOVERED: "预演台场景重试保存成功",
|
||||
PREVIS_SAVE_DRAFT_UNAVAILABLE: "预演台本地草稿不可用",
|
||||
PREVIS_CLOSE_BLOCKED: "预演台关闭被阻止",
|
||||
};
|
||||
|
||||
const LEVELS: Record<DirectorDiagnosticCode, "info" | "warning" | "error"> = {
|
||||
DIRECTOR_VIEWPORT_RENDER_FAILED: "error",
|
||||
DIRECTOR_VIEWPORT_CONTEXT_LOST: "warning",
|
||||
DIRECTOR_VIEWPORT_CONTEXT_RESTORED: "info",
|
||||
DIRECTOR_MODEL_LOAD_FAILED: "warning",
|
||||
DIRECTOR_MODEL_LOAD_RETRY: "info",
|
||||
DIRECTOR_MODEL_ADOPT_FAILED: "error",
|
||||
DIRECTOR_SAVE_FLUSH_FAILED: "error",
|
||||
DIRECTOR_SAVE_RETRY_FAILED: "error",
|
||||
DIRECTOR_SAVE_RETRY_RECOVERED: "info",
|
||||
DIRECTOR_SAVE_DRAFT_UNAVAILABLE: "error",
|
||||
DIRECTOR_CLOSE_BLOCKED: "warning",
|
||||
const LEVELS: Record<PrevisDiagnosticCode, "info" | "warning" | "error"> = {
|
||||
PREVIS_VIEWPORT_RENDER_FAILED: "error",
|
||||
PREVIS_VIEWPORT_CONTEXT_LOST: "warning",
|
||||
PREVIS_VIEWPORT_CONTEXT_RESTORED: "info",
|
||||
PREVIS_MODEL_LOAD_FAILED: "warning",
|
||||
PREVIS_MODEL_LOAD_RETRY: "info",
|
||||
PREVIS_MODEL_ADOPT_FAILED: "error",
|
||||
PREVIS_SAVE_FLUSH_FAILED: "error",
|
||||
PREVIS_SAVE_RETRY_FAILED: "error",
|
||||
PREVIS_SAVE_RETRY_RECOVERED: "info",
|
||||
PREVIS_SAVE_DRAFT_UNAVAILABLE: "error",
|
||||
PREVIS_CLOSE_BLOCKED: "warning",
|
||||
};
|
||||
|
||||
/** 对象种类枚举:只表达形态,绝不表达名称或素材来源。 */
|
||||
export const DIRECTOR_OBJECT_KINDS = ["actor", "model", "billboard", "primitive", "unknown"] as const;
|
||||
export type DirectorDiagnosticObjectKind = (typeof DIRECTOR_OBJECT_KINDS)[number];
|
||||
const OBJECT_KIND_SET: ReadonlySet<string> = new Set(DIRECTOR_OBJECT_KINDS);
|
||||
export const PREVIS_OBJECT_KINDS = ["actor", "model", "billboard", "primitive", "unknown"] as const;
|
||||
export type PrevisDiagnosticObjectKind = (typeof PREVIS_OBJECT_KINDS)[number];
|
||||
const OBJECT_KIND_SET: ReadonlySet<string> = new Set(PREVIS_OBJECT_KINDS);
|
||||
|
||||
/** 保存决策枚举:与 coordinator 的关闭决策同构,但不携带任何场景内容。 */
|
||||
export const DIRECTOR_SAVE_OUTCOMES = ["close", "offer-draft-exit", "stay"] as const;
|
||||
export type DirectorDiagnosticSaveOutcome = (typeof DIRECTOR_SAVE_OUTCOMES)[number];
|
||||
const SAVE_OUTCOME_SET: ReadonlySet<string> = new Set(DIRECTOR_SAVE_OUTCOMES);
|
||||
export const PREVIS_SAVE_OUTCOMES = ["close", "offer-draft-exit", "stay"] as const;
|
||||
export type PrevisDiagnosticSaveOutcome = (typeof PREVIS_SAVE_OUTCOMES)[number];
|
||||
const SAVE_OUTCOME_SET: ReadonlySet<string> = new Set(PREVIS_SAVE_OUTCOMES);
|
||||
|
||||
/**
|
||||
* 允许的结构化字段。全部可选,且每个字段都有独立的校验规则。
|
||||
* 这里故意不提供任何自由文本字段。
|
||||
*/
|
||||
export type DirectorDiagnosticFields = {
|
||||
export type PrevisDiagnosticFields = {
|
||||
/** 对象 id:safe-id 校验;不通过则丢弃。 */
|
||||
objectId?: string;
|
||||
/** 场景 id:safe-id 校验。绝不接收 scene 名称。 */
|
||||
sceneId?: string;
|
||||
objectKind?: DirectorDiagnosticObjectKind;
|
||||
saveOutcome?: DirectorDiagnosticSaveOutcome;
|
||||
objectKind?: PrevisDiagnosticObjectKind;
|
||||
saveOutcome?: PrevisDiagnosticSaveOutcome;
|
||||
/** 加载/保存重试次数。 */
|
||||
attempt?: number;
|
||||
/** 修订号。 */
|
||||
@@ -88,21 +88,21 @@ export type DirectorDiagnosticFields = {
|
||||
userInitiated?: boolean;
|
||||
};
|
||||
|
||||
export type DirectorDiagnosticEvent = {
|
||||
code: DirectorDiagnosticCode;
|
||||
export type PrevisDiagnosticEvent = {
|
||||
code: PrevisDiagnosticCode;
|
||||
level: "info" | "warning" | "error";
|
||||
message: string;
|
||||
fields: DirectorDiagnosticFields;
|
||||
fields: PrevisDiagnosticFields;
|
||||
};
|
||||
|
||||
const SAFE_ID = /^[A-Za-z0-9._:-]{1,96}$/;
|
||||
|
||||
export function isDirectorDiagnosticCode(value: unknown): value is DirectorDiagnosticCode {
|
||||
export function isPrevisDiagnosticCode(value: unknown): value is PrevisDiagnosticCode {
|
||||
return typeof value === "string" && CODE_SET.has(value);
|
||||
}
|
||||
|
||||
/** id 白名单校验:任何含 URL、query、路径分隔、空格、凭证形态的输入都不合法。 */
|
||||
function safeDirectorId(value: unknown): string | undefined {
|
||||
function safePrevisId(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return SAFE_ID.test(trimmed) ? trimmed : undefined;
|
||||
@@ -119,23 +119,23 @@ function boundedInteger(value: unknown, min: number, max: number): number | unde
|
||||
* 纯投影:把任意输入收敛成安全事件。未知 code 返回 null,调用方据此丢弃。
|
||||
* 未知字段、非法 id、越界数值、非布尔值一律不出现在输出里。
|
||||
*/
|
||||
export function projectDirectorDiagnostic(code: unknown, fields: unknown): DirectorDiagnosticEvent | null {
|
||||
if (!isDirectorDiagnosticCode(code)) return null;
|
||||
export function projectPrevisDiagnostic(code: unknown, fields: unknown): PrevisDiagnosticEvent | null {
|
||||
if (!isPrevisDiagnosticCode(code)) return null;
|
||||
|
||||
const source: Record<string, unknown> = fields && typeof fields === "object" && !Array.isArray(fields) ? (fields as Record<string, unknown>) : {};
|
||||
const safe: DirectorDiagnosticFields = {};
|
||||
const safe: PrevisDiagnosticFields = {};
|
||||
|
||||
const objectId = safeDirectorId(source.objectId);
|
||||
const objectId = safePrevisId(source.objectId);
|
||||
if (objectId) safe.objectId = objectId;
|
||||
|
||||
const sceneId = safeDirectorId(source.sceneId);
|
||||
const sceneId = safePrevisId(source.sceneId);
|
||||
if (sceneId) safe.sceneId = sceneId;
|
||||
|
||||
if (typeof source.objectKind === "string" && OBJECT_KIND_SET.has(source.objectKind)) {
|
||||
safe.objectKind = source.objectKind as DirectorDiagnosticObjectKind;
|
||||
safe.objectKind = source.objectKind as PrevisDiagnosticObjectKind;
|
||||
}
|
||||
if (typeof source.saveOutcome === "string" && SAVE_OUTCOME_SET.has(source.saveOutcome)) {
|
||||
safe.saveOutcome = source.saveOutcome as DirectorDiagnosticSaveOutcome;
|
||||
safe.saveOutcome = source.saveOutcome as PrevisDiagnosticSaveOutcome;
|
||||
}
|
||||
|
||||
const attempt = boundedInteger(source.attempt, 0, 999);
|
||||
@@ -157,7 +157,7 @@ export function projectDirectorDiagnostic(code: unknown, fields: unknown): Direc
|
||||
* 因此后缀里只可能出现受限字符集。完整签名同时用于去重:
|
||||
* 不同 objectId / sceneId 必须是不同事件,不能在去重窗口内互相吞掉。
|
||||
*/
|
||||
export function formatDirectorDiagnosticCode(event: DirectorDiagnosticEvent): string {
|
||||
export function formatPrevisDiagnosticCode(event: PrevisDiagnosticEvent): string {
|
||||
const parts: string[] = [event.code];
|
||||
if (event.fields.sceneId) parts.push("scene=" + event.fields.sceneId);
|
||||
if (event.fields.objectId) parts.push("object=" + event.fields.objectId);
|
||||
@@ -170,8 +170,8 @@ export function formatDirectorDiagnosticCode(event: DirectorDiagnosticEvent): st
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** 从 DirectorObject 的形态推导枚举,不读取 name / url / storageKey。 */
|
||||
export function directorDiagnosticObjectKind(object: { kind?: string; primitive?: string } | null | undefined): DirectorDiagnosticObjectKind {
|
||||
/** 从 PrevisObject 的形态推导枚举,不读取 name / url / storageKey。 */
|
||||
export function previsDiagnosticObjectKind(object: { kind?: string; primitive?: string } | null | undefined): PrevisDiagnosticObjectKind {
|
||||
if (!object) return "unknown";
|
||||
if (object.kind === "actor") return "actor";
|
||||
if (object.kind === "model") return "model";
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
import { directorGestureIdle, reduceDirectorGesture } from "./director-animation-semantics";
|
||||
import { previsGestureIdle, reducePrevisGesture } from "./previs-animation-semantics";
|
||||
|
||||
export type DirectorTransactionHooks<TSnapshot> = {
|
||||
export type PrevisTransactionHooks<TSnapshot> = {
|
||||
/** 读取当前值;返回 null 表示无法开始/结束事务。 */
|
||||
read: () => TSnapshot | null;
|
||||
/** 取消路径:把快照写回。 */
|
||||
@@ -13,7 +13,7 @@ export type DirectorTransactionHooks<TSnapshot> = {
|
||||
terminateDrag?: () => void;
|
||||
};
|
||||
|
||||
export type DirectorTransaction = {
|
||||
export type PrevisTransaction = {
|
||||
begin: () => void;
|
||||
end: (outcome: "commit" | "cancel") => void;
|
||||
active: () => boolean;
|
||||
@@ -23,8 +23,8 @@ export type DirectorTransaction = {
|
||||
* 一次手势一个事务:begin 抓快照,end 恰好产生一个终态。
|
||||
* 幂等——非活跃时的 end 是空操作,终态后可以重新 begin。
|
||||
*/
|
||||
export function createDirectorTransaction<TSnapshot>(hooks: DirectorTransactionHooks<TSnapshot>): DirectorTransaction {
|
||||
let state = directorGestureIdle;
|
||||
export function createPrevisTransaction<TSnapshot>(hooks: PrevisTransactionHooks<TSnapshot>): PrevisTransaction {
|
||||
let state = previsGestureIdle;
|
||||
// 快照可能是任何值(含 0 / ""),所以用独立标记表示「有快照」,不能用真值判断。
|
||||
let snapshot: TSnapshot | null = null;
|
||||
let hasSnapshot = false;
|
||||
@@ -35,17 +35,17 @@ export function createDirectorTransaction<TSnapshot>(hooks: DirectorTransactionH
|
||||
if (current === null || current === undefined) return;
|
||||
snapshot = current;
|
||||
hasSnapshot = true;
|
||||
state = reduceDirectorGesture(state, "start");
|
||||
state = reducePrevisGesture(state, "start");
|
||||
hooks.setActive(true);
|
||||
},
|
||||
end: (outcome) => {
|
||||
if (!state.active) return;
|
||||
const base = snapshot;
|
||||
const settled = reduceDirectorGesture(state, outcome);
|
||||
const settled = reducePrevisGesture(state, outcome);
|
||||
const hadSnapshot = hasSnapshot;
|
||||
snapshot = null;
|
||||
hasSnapshot = false;
|
||||
state = directorGestureIdle;
|
||||
state = previsGestureIdle;
|
||||
// 先进入终态再结束第三方拖拽:stdlib 在 domElement.ownerDocument 注册 pointerup,
|
||||
// 其 pointerUp({button:0}) 会 dispatch mouseUp 后清 dragging=false / axis=null。
|
||||
// 该回调会回到本事务,此时已非活跃,因此不会二次提交。
|
||||
@@ -66,16 +66,16 @@ export function createDirectorTransaction<TSnapshot>(hooks: DirectorTransactionH
|
||||
};
|
||||
}
|
||||
|
||||
export type DirectorListenerTarget = {
|
||||
export type PrevisListenerTarget = {
|
||||
addEventListener: (type: string, listener: (event: Event) => void) => void;
|
||||
removeEventListener: (type: string, listener: (event: Event) => void) => void;
|
||||
};
|
||||
|
||||
export type DirectorTerminalTargets = {
|
||||
export type PrevisTerminalTargets = {
|
||||
/** 承载 keydown / blur / pointercancel / pointerup 的目标(通常是 window)。 */
|
||||
window: DirectorListenerTarget;
|
||||
window: PrevisListenerTarget;
|
||||
/** 承载 visibilitychange 的目标(通常是 document)。 */
|
||||
document: DirectorListenerTarget;
|
||||
document: PrevisListenerTarget;
|
||||
/** 读取可见性;hidden 视为「离开」而非「放弃」。 */
|
||||
isHidden: () => boolean;
|
||||
};
|
||||
@@ -90,7 +90,7 @@ export type DirectorTerminalTargets = {
|
||||
* 失焦或切页只是「离开」,用户已经把对象拖到那里了,不能把它弹回原位。
|
||||
* - Escape / pointercancel → cancel 并恢复快照,这是用户明确表达的放弃。
|
||||
*/
|
||||
export function installDirectorTerminalListeners(transaction: DirectorTransaction, targets: DirectorTerminalTargets) {
|
||||
export function installPrevisTerminalListeners(transaction: PrevisTransaction, targets: PrevisTerminalTargets) {
|
||||
const cancel = () => transaction.end("cancel");
|
||||
const commit = () => transaction.end("commit");
|
||||
const onKeyDown = (event: Event) => {
|
||||
+16
-16
@@ -1,15 +1,15 @@
|
||||
import type { DirectorRenderMode } from "@/types/director";
|
||||
import type { PrevisRenderMode } from "@/types/previs";
|
||||
|
||||
/**
|
||||
* 导演台一级模式。
|
||||
* 预演台一级模式。
|
||||
*
|
||||
* mode 是工作台 UI 状态,绝不写入 DirectorScene —— 场景是内容,模式是当前怎么看内容。
|
||||
* mode 是工作台 UI 状态,绝不写入 PrevisScene —— 场景是内容,模式是当前怎么看内容。
|
||||
* 这里只做纯粹的「模式 -> 能力」映射与切换清理,组件层照着 capabilities 决定显示什么。
|
||||
*/
|
||||
|
||||
export type DirectorMode = "layout" | "pose" | "animate" | "camera";
|
||||
export type PrevisMode = "layout" | "pose" | "animate" | "camera";
|
||||
|
||||
export type DirectorModeCapabilities = {
|
||||
export type PrevisModeCapabilities = {
|
||||
/** 时间轴是否显示。只有动画模式显示。 */
|
||||
timeline: boolean;
|
||||
/** 是否允许记录/删除关键帧与 Auto Key。只有动画模式允许。 */
|
||||
@@ -19,10 +19,10 @@ export type DirectorModeCapabilities = {
|
||||
/** 是否突出 shot/camera 检查器与运镜入口。摄影机模式突出。 */
|
||||
cameraTools: boolean;
|
||||
/** 该模式允许的渲染视图。摆场不给深度/法线这类高级视图。 */
|
||||
renderModes: DirectorRenderMode[];
|
||||
renderModes: PrevisRenderMode[];
|
||||
};
|
||||
|
||||
const CAPABILITIES: Record<DirectorMode, DirectorModeCapabilities> = {
|
||||
const CAPABILITIES: Record<PrevisMode, PrevisModeCapabilities> = {
|
||||
// 摆场:先把人和物放对位置,隐藏骨骼、关键帧、Auto Key、深度/法线。
|
||||
layout: { timeline: false, keyframes: false, bones: false, cameraTools: false, renderModes: ["beauty", "clay"] },
|
||||
// 姿态:调骨骼与姿势,但不默认制造关键帧。
|
||||
@@ -33,24 +33,24 @@ const CAPABILITIES: Record<DirectorMode, DirectorModeCapabilities> = {
|
||||
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 }> = [
|
||||
export const PREVIS_MODES: Array<{ mode: PrevisMode; 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 const PREVIS_DEFAULT_MODE: PrevisMode = "layout";
|
||||
|
||||
export function directorModeCapabilities(mode: DirectorMode): DirectorModeCapabilities {
|
||||
return CAPABILITIES[mode] ?? CAPABILITIES[DIRECTOR_DEFAULT_MODE];
|
||||
export function previsModeCapabilities(mode: PrevisMode): PrevisModeCapabilities {
|
||||
return CAPABILITIES[mode] ?? CAPABILITIES[PREVIS_DEFAULT_MODE];
|
||||
}
|
||||
|
||||
export type DirectorModeTransition = {
|
||||
mode: DirectorMode;
|
||||
export type PrevisModeTransition = {
|
||||
mode: PrevisMode;
|
||||
playing: boolean;
|
||||
autoKey: boolean;
|
||||
renderMode: DirectorRenderMode;
|
||||
renderMode: PrevisRenderMode;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -62,8 +62,8 @@ export type DirectorModeTransition = {
|
||||
*
|
||||
* renderMode 同时被夹回目标模式允许的集合:不能带着深度视图退回摆场。
|
||||
*/
|
||||
export function resolveDirectorModeTransition(input: { mode: DirectorMode; playing: boolean; autoKey: boolean; renderMode: DirectorRenderMode }): DirectorModeTransition {
|
||||
const capabilities = directorModeCapabilities(input.mode);
|
||||
export function resolvePrevisModeTransition(input: { mode: PrevisMode; playing: boolean; autoKey: boolean; renderMode: PrevisRenderMode }): PrevisModeTransition {
|
||||
const capabilities = previsModeCapabilities(input.mode);
|
||||
return {
|
||||
mode: input.mode,
|
||||
playing: capabilities.timeline ? input.playing : false,
|
||||
+57
-57
@@ -1,14 +1,14 @@
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
import type { DirectorMode } from "@/lib/canvas/director/director-modes";
|
||||
import type { PrevisMode } from "@/lib/canvas/previs/previs-modes";
|
||||
|
||||
/**
|
||||
* 导演台首次上手引导。
|
||||
* 预演台首次上手引导。
|
||||
*
|
||||
* 为什么存在:导演台把模板、四个一级模式、对象操作、保存/导出堆在同一个工作台里,
|
||||
* 为什么存在:预演台把模板、四个一级模式、对象操作、保存/导出堆在同一个工作台里,
|
||||
* 第一次进来的人没有任何线索该先做什么。引导给出一条确定的最短路径。
|
||||
*
|
||||
* 硬约束:
|
||||
* - 纯状态机 + 持久化,绝不触碰 DirectorScene,也不写 undo/redo 历史:
|
||||
* - 纯状态机 + 持久化,绝不触碰 PrevisScene,也不写 undo/redo 历史:
|
||||
* 引导是「用户看到什么提示」,不是场景内容。
|
||||
* - 进度按用户隔离。所有导出 API 必须显式收到非空 scope,禁止内部兜底成 guest ——
|
||||
* 默认 scope 会让 A 账号的引导状态泄漏给 B 账号。
|
||||
@@ -17,23 +17,23 @@ import type { DirectorMode } from "@/lib/canvas/director/director-modes";
|
||||
* - 缺失、损坏、版本不符的持久数据一律回落到初始进度;只有存储层本身失败才向上抛。
|
||||
*/
|
||||
|
||||
export type DirectorOnboardingStepId = "actor" | "move" | "pose" | "path" | "camera" | "apply";
|
||||
export type PrevisOnboardingStepId = "actor" | "move" | "pose" | "path" | "camera" | "apply";
|
||||
|
||||
/** active 才展示。dismissed(用户跳过)与 completed(走完)都不再展示,且不可被 next/back 唤醒。 */
|
||||
export type DirectorOnboardingStatus = "active" | "dismissed" | "completed";
|
||||
export type PrevisOnboardingStatus = "active" | "dismissed" | "completed";
|
||||
|
||||
/**
|
||||
* 进度 schema 版本。
|
||||
* 步骤集合发生语义变化(增删步骤、改变顺序)时必须 +1:
|
||||
* 旧进度里的 stepId 在新步骤表里可能不存在,继续沿用会把用户卡在不存在的步骤上。
|
||||
*/
|
||||
export const DIRECTOR_ONBOARDING_VERSION = 2;
|
||||
export const PREVIS_ONBOARDING_VERSION = 2;
|
||||
|
||||
/** localforage 键名。真实键由 localForageStorageForScope 追加 `:user:<scope>`。 */
|
||||
export const DIRECTOR_ONBOARDING_KEY = "director-onboarding-v2";
|
||||
export const PREVIS_ONBOARDING_KEY = "previs-onboarding-v2";
|
||||
|
||||
export type DirectorOnboardingStep = {
|
||||
id: DirectorOnboardingStepId;
|
||||
export type PrevisOnboardingStep = {
|
||||
id: PrevisOnboardingStepId;
|
||||
title: string;
|
||||
/** 一句话说清「在哪儿点什么」,不解释概念。 */
|
||||
detail: string;
|
||||
@@ -41,11 +41,11 @@ export type DirectorOnboardingStep = {
|
||||
* 该步骤应该在哪个一级模式里完成。
|
||||
* 模式巡览步骤本身没有单一归属,故为 undefined —— 由 UI 决定是否代为切换模式。
|
||||
*/
|
||||
mode?: DirectorMode;
|
||||
mode?: PrevisMode;
|
||||
};
|
||||
|
||||
/** 引导步骤按首次使用的最短操作路径排列:添加演员 → 移动 → 调姿 → 轨迹 → CAM → 应用。 */
|
||||
export const DIRECTOR_ONBOARDING_STEPS: DirectorOnboardingStep[] = [
|
||||
export const PREVIS_ONBOARDING_STEPS: PrevisOnboardingStep[] = [
|
||||
{ id: "actor", title: "添加并选中演员", detail: "在左侧「快速添加」点演员;模板已有演员时,直接在场景树或视口选中它。", mode: "layout" },
|
||||
{ id: "move", title: "把演员移动到表演位置", detail: "保持摆场模式,在底部 dock 选移动工具(W),拖动轴手柄;右栏可输入精确坐标。", mode: "layout" },
|
||||
{ id: "pose", title: "调整演员姿态", detail: "切到姿态模式,在右侧选择姿势预设;模型有骨骼时还可选择手臂、腿和躯干微调。", mode: "pose" },
|
||||
@@ -54,19 +54,19 @@ export const DIRECTOR_ONBOARDING_STEPS: DirectorOnboardingStep[] = [
|
||||
{ id: "apply", title: "应用到镜头", detail: "确认场景、姿态、走位和 CAM 构图后,点顶栏「应用到镜头」生成静帧并回写画布。", mode: "camera" },
|
||||
];
|
||||
|
||||
export type DirectorOnboardingProgress = {
|
||||
export type PrevisOnboardingProgress = {
|
||||
version: number;
|
||||
status: DirectorOnboardingStatus;
|
||||
stepId: DirectorOnboardingStepId;
|
||||
status: PrevisOnboardingStatus;
|
||||
stepId: PrevisOnboardingStepId;
|
||||
};
|
||||
|
||||
export const directorOnboardingInitial: DirectorOnboardingProgress = {
|
||||
version: DIRECTOR_ONBOARDING_VERSION,
|
||||
export const previsOnboardingInitial: PrevisOnboardingProgress = {
|
||||
version: PREVIS_ONBOARDING_VERSION,
|
||||
status: "active",
|
||||
stepId: DIRECTOR_ONBOARDING_STEPS[0].id,
|
||||
stepId: PREVIS_ONBOARDING_STEPS[0].id,
|
||||
};
|
||||
|
||||
export type DirectorOnboardingAction = "next" | "back" | "dismiss" | "complete" | "reset";
|
||||
export type PrevisOnboardingAction = "next" | "back" | "dismiss" | "complete" | "reset";
|
||||
|
||||
/**
|
||||
* 引导状态机。
|
||||
@@ -78,27 +78,27 @@ export type DirectorOnboardingAction = "next" | "back" | "dismiss" | "complete"
|
||||
* - 首步 back 保持不动,不越界,也不隐式关闭引导。
|
||||
* - 当前 stepId 不在步骤表里(旧数据、被外部改写)时回到初始进度,而不是把 -1 当下标继续算。
|
||||
*/
|
||||
export function reduceDirectorOnboarding(progress: DirectorOnboardingProgress, action: DirectorOnboardingAction): DirectorOnboardingProgress {
|
||||
if (action === "reset") return directorOnboardingInitial;
|
||||
export function reducePrevisOnboarding(progress: PrevisOnboardingProgress, action: PrevisOnboardingAction): PrevisOnboardingProgress {
|
||||
if (action === "reset") return previsOnboardingInitial;
|
||||
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;
|
||||
const index = PREVIS_ONBOARDING_STEPS.findIndex((step) => step.id === progress.stepId);
|
||||
if (index < 0) return previsOnboardingInitial;
|
||||
|
||||
if (action === "back") {
|
||||
if (index === 0) return progress;
|
||||
return { ...progress, stepId: DIRECTOR_ONBOARDING_STEPS[index - 1].id };
|
||||
return { ...progress, stepId: PREVIS_ONBOARDING_STEPS[index - 1].id };
|
||||
}
|
||||
|
||||
const next = DIRECTOR_ONBOARDING_STEPS[index + 1];
|
||||
const next = PREVIS_ONBOARDING_STEPS[index + 1];
|
||||
if (!next) return { ...progress, status: "completed" };
|
||||
return { ...progress, stepId: next.id };
|
||||
}
|
||||
|
||||
export type DirectorOnboardingView = {
|
||||
step: DirectorOnboardingStep;
|
||||
export type PrevisOnboardingView = {
|
||||
step: PrevisOnboardingStep;
|
||||
/** 1 起数,直接用于「第 N 步 / 共 M 步」。 */
|
||||
position: number;
|
||||
total: number;
|
||||
@@ -110,24 +110,24 @@ export type DirectorOnboardingView = {
|
||||
* 展示态。返回 null 表示不该显示引导 —— 组件层只看这一个判据,
|
||||
* 不自己再拼 status 与步骤下标,避免出现第二套可见性规则。
|
||||
*/
|
||||
export function resolveDirectorOnboardingView(progress: DirectorOnboardingProgress): DirectorOnboardingView | null {
|
||||
export function resolvePrevisOnboardingView(progress: PrevisOnboardingProgress): PrevisOnboardingView | null {
|
||||
if (progress.status !== "active") return null;
|
||||
const index = DIRECTOR_ONBOARDING_STEPS.findIndex((step) => step.id === progress.stepId);
|
||||
const index = PREVIS_ONBOARDING_STEPS.findIndex((step) => step.id === progress.stepId);
|
||||
if (index < 0) return null;
|
||||
return {
|
||||
step: DIRECTOR_ONBOARDING_STEPS[index],
|
||||
step: PREVIS_ONBOARDING_STEPS[index],
|
||||
position: index + 1,
|
||||
total: DIRECTOR_ONBOARDING_STEPS.length,
|
||||
total: PREVIS_ONBOARDING_STEPS.length,
|
||||
isFirst: index === 0,
|
||||
isLast: index === DIRECTOR_ONBOARDING_STEPS.length - 1,
|
||||
isLast: index === PREVIS_ONBOARDING_STEPS.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
function isStepId(value: unknown): value is DirectorOnboardingStepId {
|
||||
return typeof value === "string" && DIRECTOR_ONBOARDING_STEPS.some((step) => step.id === value);
|
||||
function isStepId(value: unknown): value is PrevisOnboardingStepId {
|
||||
return typeof value === "string" && PREVIS_ONBOARDING_STEPS.some((step) => step.id === value);
|
||||
}
|
||||
|
||||
function isStatus(value: unknown): value is DirectorOnboardingStatus {
|
||||
function isStatus(value: unknown): value is PrevisOnboardingStatus {
|
||||
return value === "active" || value === "dismissed" || value === "completed";
|
||||
}
|
||||
|
||||
@@ -138,23 +138,23 @@ function isStatus(value: unknown): value is DirectorOnboardingStatus {
|
||||
* 也不要把用户卡在一个无法解释的状态里。覆盖:非 JSON、非对象、数组、版本不符、
|
||||
* status/stepId 不在枚举内。
|
||||
*/
|
||||
export function parseDirectorOnboardingProgress(raw: string | null | undefined): DirectorOnboardingProgress {
|
||||
if (!raw) return directorOnboardingInitial;
|
||||
export function parsePrevisOnboardingProgress(raw: string | null | undefined): PrevisOnboardingProgress {
|
||||
if (!raw) return previsOnboardingInitial;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return directorOnboardingInitial;
|
||||
return previsOnboardingInitial;
|
||||
}
|
||||
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 };
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return previsOnboardingInitial;
|
||||
const candidate = parsed as Partial<PrevisOnboardingProgress>;
|
||||
if (candidate.version !== PREVIS_ONBOARDING_VERSION) return previsOnboardingInitial;
|
||||
if (!isStatus(candidate.status) || !isStepId(candidate.stepId)) return previsOnboardingInitial;
|
||||
return { version: PREVIS_ONBOARDING_VERSION, status: candidate.status, stepId: candidate.stepId };
|
||||
}
|
||||
|
||||
/** 只要求读写能力:既是测试注入内存实现的接缝,也避免依赖 zustand StateStorage 的全量接口。 */
|
||||
export type DirectorOnboardingStorage = {
|
||||
export type PrevisOnboardingStorage = {
|
||||
getItem: (name: string) => string | null | Promise<string | null>;
|
||||
setItem: (name: string, value: string) => void | Promise<void>;
|
||||
};
|
||||
@@ -169,7 +169,7 @@ export type DirectorOnboardingStorage = {
|
||||
*/
|
||||
function requireScope(scope: string) {
|
||||
const normalized = scope.trim();
|
||||
if (!normalized) throw new Error("缺少导演台引导的用户 scope");
|
||||
if (!normalized) throw new Error("缺少预演台引导的用户 scope");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -178,16 +178,16 @@ function requireScope(scope: string) {
|
||||
* 数据脏 -> 初始进度;存储层(IndexedDB)失败 -> 向上抛,由调用方决定是否隐藏引导,
|
||||
* 不在这里 catch 成默认值假装读到了。
|
||||
*/
|
||||
export async function loadDirectorOnboardingProgress(scope: string, storage?: DirectorOnboardingStorage): Promise<DirectorOnboardingProgress> {
|
||||
export async function loadPrevisOnboardingProgress(scope: string, storage?: PrevisOnboardingStorage): Promise<PrevisOnboardingProgress> {
|
||||
const normalizedScope = requireScope(scope);
|
||||
const target = storage ?? localForageStorageForScope(normalizedScope);
|
||||
return parseDirectorOnboardingProgress(await target.getItem(DIRECTOR_ONBOARDING_KEY));
|
||||
return parsePrevisOnboardingProgress(await target.getItem(PREVIS_ONBOARDING_KEY));
|
||||
}
|
||||
|
||||
export async function saveDirectorOnboardingProgress(scope: string, progress: DirectorOnboardingProgress, storage?: DirectorOnboardingStorage): Promise<void> {
|
||||
export async function savePrevisOnboardingProgress(scope: string, progress: PrevisOnboardingProgress, storage?: PrevisOnboardingStorage): 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 }));
|
||||
await target.setItem(PREVIS_ONBOARDING_KEY, JSON.stringify({ version: PREVIS_ONBOARDING_VERSION, status: progress.status, stepId: progress.stepId }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,11 +198,11 @@ export async function saveDirectorOnboardingProgress(scope: string, progress: Di
|
||||
* 调用 next 之类的恒等动作会在校验之前就 return progress,空 scope 也能悄悄跑过去。
|
||||
* 无变化的动作仍然不写盘。
|
||||
*/
|
||||
export async function advanceDirectorOnboarding(scope: string, progress: DirectorOnboardingProgress, action: DirectorOnboardingAction, storage?: DirectorOnboardingStorage): Promise<DirectorOnboardingProgress> {
|
||||
export async function advancePrevisOnboarding(scope: string, progress: PrevisOnboardingProgress, action: PrevisOnboardingAction, storage?: PrevisOnboardingStorage): Promise<PrevisOnboardingProgress> {
|
||||
const normalizedScope = requireScope(scope);
|
||||
const next = reduceDirectorOnboarding(progress, action);
|
||||
const next = reducePrevisOnboarding(progress, action);
|
||||
if (next === progress) return progress;
|
||||
await saveDirectorOnboardingProgress(normalizedScope, next, storage);
|
||||
await savePrevisOnboardingProgress(normalizedScope, next, storage);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -210,13 +210,13 @@ export async function advanceDirectorOnboarding(scope: string, progress: Directo
|
||||
* 独立的重启 API。不依赖调用方持有当前内存进度;设置页的「重新开始引导」
|
||||
* 按钮可以直接落盘初始状态,对已 dismissed/completed 的账号同样生效。
|
||||
*/
|
||||
export async function resetDirectorOnboardingProgress(scope: string, storage?: DirectorOnboardingStorage): Promise<DirectorOnboardingProgress> {
|
||||
export async function resetPrevisOnboardingProgress(scope: string, storage?: PrevisOnboardingStorage): Promise<PrevisOnboardingProgress> {
|
||||
const normalizedScope = requireScope(scope);
|
||||
await saveDirectorOnboardingProgress(normalizedScope, directorOnboardingInitial, storage);
|
||||
return directorOnboardingInitial;
|
||||
await savePrevisOnboardingProgress(normalizedScope, previsOnboardingInitial, storage);
|
||||
return previsOnboardingInitial;
|
||||
}
|
||||
|
||||
export type DirectorOnboardingGate = {
|
||||
export type PrevisOnboardingGate = {
|
||||
/** 尝试进入互斥区;已有一次未完成的调用时返回 false,调用方必须放弃本次动作。 */
|
||||
tryEnter: () => boolean;
|
||||
/** 释放互斥区。scope/enabled 变化(换账号)时也要调用,否则旧账号的占用会永久卡死新账号。 */
|
||||
@@ -230,7 +230,7 @@ export type DirectorOnboardingGate = {
|
||||
* 两次点击完全可能发生在同一次渲染之间,仅靠 busy state 挡不住这种同步重入。
|
||||
* release 是幂等的:未锁定时调用不抛错,方便在 effect 里无条件清理。
|
||||
*/
|
||||
export function createDirectorOnboardingGate(): DirectorOnboardingGate {
|
||||
export function createPrevisOnboardingGate(): PrevisOnboardingGate {
|
||||
let locked = false;
|
||||
return {
|
||||
tryEnter: () => {
|
||||
@@ -0,0 +1,115 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import type { PrevisKeyframe, PrevisTransform, PrevisVec3 } from "@/types/previs";
|
||||
|
||||
export type PrevisPathWaypoint = {
|
||||
time: number;
|
||||
position: PrevisVec3;
|
||||
};
|
||||
|
||||
const POINT_EPSILON = 1e-5;
|
||||
|
||||
function finitePoint(point: readonly number[]): point is PrevisVec3 {
|
||||
return point.length === 3 && point.every(Number.isFinite);
|
||||
}
|
||||
|
||||
function pointDistance(a: PrevisVec3, b: PrevisVec3) {
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
}
|
||||
|
||||
function segmentDistance(point: PrevisVec3, start: PrevisVec3, end: PrevisVec3) {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const dz = end[2] - start[2];
|
||||
const lengthSquared = dx * dx + dy * dy + dz * dz;
|
||||
if (lengthSquared <= POINT_EPSILON) return pointDistance(point, start);
|
||||
const t = Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy + (point[2] - start[2]) * dz) / lengthSquared));
|
||||
return Math.hypot(point[0] - (start[0] + dx * t), point[1] - (start[1] + dy * t), point[2] - (start[2] + dz * t));
|
||||
}
|
||||
|
||||
/** Removes invalid/repeated samples and preserves meaningful corners in 3D. */
|
||||
export function simplifyPrevisPath(points: readonly PrevisVec3[], tolerance = 0.025): PrevisVec3[] {
|
||||
if (!Number.isFinite(tolerance) || tolerance <= 0) throw new Error("轨迹精度无效");
|
||||
const clean: PrevisVec3[] = [];
|
||||
for (const point of points) {
|
||||
if (!finitePoint(point)) continue;
|
||||
const next = [...point] as PrevisVec3;
|
||||
if (!clean.length || pointDistance(clean[clean.length - 1], next) > POINT_EPSILON) clean.push(next);
|
||||
}
|
||||
if (clean.length < 3) return clean;
|
||||
const keep = new Set<number>([0, clean.length - 1]);
|
||||
const stack: Array<[number, number]> = [[0, clean.length - 1]];
|
||||
while (stack.length) {
|
||||
const [start, end] = stack.pop()!;
|
||||
let farthestIndex = -1;
|
||||
let farthestDistance = tolerance;
|
||||
for (let index = start + 1; index < end; index += 1) {
|
||||
const distance = segmentDistance(clean[index], clean[start], clean[end]);
|
||||
if (distance > farthestDistance) {
|
||||
farthestDistance = distance;
|
||||
farthestIndex = index;
|
||||
}
|
||||
}
|
||||
if (farthestIndex >= 0) {
|
||||
keep.add(farthestIndex);
|
||||
stack.push([start, farthestIndex], [farthestIndex, end]);
|
||||
}
|
||||
}
|
||||
return [...keep].sort((a, b) => a - b).map((index) => clean[index]);
|
||||
}
|
||||
|
||||
/** Maps points to time by travelled distance, independent of pointer speed/event count. */
|
||||
export function timePrevisPath(points: readonly PrevisVec3[], start: number, duration: number): PrevisPathWaypoint[] {
|
||||
if (!Number.isFinite(start) || start < 0) throw new Error("轨迹起始时间无效");
|
||||
if (!Number.isFinite(duration) || duration <= 0) throw new Error("轨迹时长无效");
|
||||
const clean: PrevisVec3[] = [];
|
||||
for (const point of points) {
|
||||
if (!finitePoint(point)) continue;
|
||||
const next = [...point] as PrevisVec3;
|
||||
if (!clean.length || pointDistance(clean[clean.length - 1], next) > POINT_EPSILON) clean.push(next);
|
||||
}
|
||||
if (!clean.length) return [];
|
||||
if (clean.length === 1) return [{ time: start, position: clean[0] }];
|
||||
const lengths = clean.map((point, index) => index === 0 ? 0 : pointDistance(clean[index - 1], point));
|
||||
const total = lengths.reduce((sum, value) => sum + value, 0);
|
||||
if (total <= POINT_EPSILON) return [{ time: start, position: clean[0] }];
|
||||
let travelled = 0;
|
||||
return clean.map((position, index) => {
|
||||
travelled += lengths[index];
|
||||
return { time: index === clean.length - 1 ? start + duration : start + (travelled / total) * duration, position };
|
||||
});
|
||||
}
|
||||
|
||||
export function previsPathHeading(points: readonly PrevisVec3[], index: number, fallback = 0) {
|
||||
if (points.length < 2) return fallback;
|
||||
const current = points[Math.max(0, Math.min(index, points.length - 1))];
|
||||
const next = points[Math.min(points.length - 1, Math.max(0, index + 1))];
|
||||
const previous = points[Math.max(0, index - 1)];
|
||||
const forward = pointDistance(current, next) > POINT_EPSILON ? next : current;
|
||||
const backward = pointDistance(previous, current) > POINT_EPSILON ? previous : current;
|
||||
const dx = forward[0] - backward[0];
|
||||
const dz = forward[2] - backward[2];
|
||||
return Math.hypot(dx, dz) > POINT_EPSILON ? Math.atan2(dx, dz) : fallback;
|
||||
}
|
||||
|
||||
export function buildPrevisPathKeyframes(input: {
|
||||
base: PrevisTransform;
|
||||
points: readonly PrevisVec3[];
|
||||
start?: number;
|
||||
duration: number;
|
||||
orientToPath?: boolean;
|
||||
}): PrevisKeyframe[] {
|
||||
const start = input.start ?? 0;
|
||||
const simplified = simplifyPrevisPath(input.points, 0.025);
|
||||
const waypoints = timePrevisPath(simplified, start, input.duration);
|
||||
return waypoints.map((waypoint, index) => ({
|
||||
id: nanoid(),
|
||||
time: waypoint.time,
|
||||
easing: "smooth",
|
||||
transform: {
|
||||
position: waypoint.position,
|
||||
rotation: input.orientToPath ? [input.base.rotation[0], previsPathHeading(waypoints.map((item) => item.position), index, input.base.rotation[1]), input.base.rotation[2]] : input.base.rotation,
|
||||
scale: input.base.scale,
|
||||
},
|
||||
}));
|
||||
}
|
||||
+23
-23
@@ -1,17 +1,17 @@
|
||||
import type { DirectorObject, DirectorVec3 } from "../../../types/director";
|
||||
import type { PrevisObject, PrevisVec3 } from "../../../types/previs";
|
||||
|
||||
export type DirectorFootprint = { width: number; depth: number };
|
||||
export type DirectorPlacementCandidate = { footprint: DirectorFootprint; position: DirectorVec3 };
|
||||
export type PrevisFootprint = { width: number; depth: number };
|
||||
export type PrevisPlacementCandidate = { footprint: PrevisFootprint; position: PrevisVec3 };
|
||||
|
||||
/** 摆放最小间隙,保证新对象与既有对象之间肉眼可分。 */
|
||||
export const DIRECTOR_PLACEMENT_MARGIN = 0.25;
|
||||
export const PREVIS_PLACEMENT_MARGIN = 0.25;
|
||||
const SEARCH_STEP = 0.75;
|
||||
const MAX_RINGS = 24;
|
||||
const SAMPLES_PER_RING = 12;
|
||||
// 模型导入后被 normalizeModel 缩放到最大边 2,未知尺寸时按此保守占位。
|
||||
const MODEL_FOOTPRINT = 2;
|
||||
|
||||
function baseFootprint(object: Pick<DirectorObject, "kind" | "primitive">): DirectorFootprint {
|
||||
function baseFootprint(object: Pick<PrevisObject, "kind" | "primitive">): PrevisFootprint {
|
||||
if (object.kind === "actor" || object.primitive === "character") return { width: 0.8, depth: 0.8 };
|
||||
if (object.kind === "model") return { width: MODEL_FOOTPRINT, depth: MODEL_FOOTPRINT };
|
||||
if (object.kind === "billboard" || object.primitive === "plane") return { width: 1.6, depth: 0.3 };
|
||||
@@ -21,7 +21,7 @@ function baseFootprint(object: Pick<DirectorObject, "kind" | "primitive">): Dire
|
||||
}
|
||||
|
||||
/** XZ 平面占位:只用 scale 的 x/z,取绝对值以容忍负缩放;不参与 Y 判断。 */
|
||||
export function directorObjectFootprint(object: Pick<DirectorObject, "kind" | "primitive" | "transform">): DirectorFootprint {
|
||||
export function previsObjectFootprint(object: Pick<PrevisObject, "kind" | "primitive" | "transform">): PrevisFootprint {
|
||||
const base = baseFootprint(object);
|
||||
const scaleX = Math.abs(object.transform?.scale?.[0] ?? 1);
|
||||
const scaleZ = Math.abs(object.transform?.scale?.[2] ?? 1);
|
||||
@@ -31,7 +31,7 @@ export function directorObjectFootprint(object: Pick<DirectorObject, "kind" | "p
|
||||
};
|
||||
}
|
||||
|
||||
function overlaps(a: DirectorPlacementCandidate, b: DirectorPlacementCandidate, margin: number) {
|
||||
function overlaps(a: PrevisPlacementCandidate, b: PrevisPlacementCandidate, margin: number) {
|
||||
const halfX = (a.footprint.width + b.footprint.width) / 2 + margin;
|
||||
const halfZ = (a.footprint.depth + b.footprint.depth) / 2 + margin;
|
||||
return Math.abs(a.position[0] - b.position[0]) < halfX && Math.abs(a.position[2] - b.position[2]) < halfZ;
|
||||
@@ -56,22 +56,22 @@ function candidateOffsets() {
|
||||
* 隐藏对象同样占位;ring 采样耗尽时回退到「所有占位最右边界之外」的确定性位置,
|
||||
* 该位置在有限 existing 集合下必然不与任何 AABB 相交。
|
||||
*/
|
||||
export function resolveDirectorPlacement(input: { object: Pick<DirectorObject, "kind" | "primitive" | "transform">; existing: Array<Pick<DirectorObject, "kind" | "primitive" | "transform">>; margin?: number }): DirectorVec3 {
|
||||
export function resolvePrevisPlacement(input: { object: Pick<PrevisObject, "kind" | "primitive" | "transform">; existing: Array<Pick<PrevisObject, "kind" | "primitive" | "transform">>; margin?: number }): PrevisVec3 {
|
||||
const { object, existing } = input;
|
||||
const margin = input.margin ?? DIRECTOR_PLACEMENT_MARGIN;
|
||||
const margin = input.margin ?? PREVIS_PLACEMENT_MARGIN;
|
||||
const desired = object.transform?.position ?? [0, 0, 0];
|
||||
const safeY = Number.isFinite(desired[1]) ? desired[1] : 0;
|
||||
const originX = Number.isFinite(desired[0]) ? desired[0] : 0;
|
||||
const originZ = Number.isFinite(desired[2]) ? desired[2] : 0;
|
||||
const footprint = directorObjectFootprint(object);
|
||||
const footprint = previsObjectFootprint(object);
|
||||
const occupied = existing.flatMap((item) => {
|
||||
const position = item.transform?.position;
|
||||
const x = Number.isFinite(position?.[0]) ? position![0] : 0;
|
||||
const z = Number.isFinite(position?.[2]) ? position![2] : 0;
|
||||
return [{ footprint: directorObjectFootprint(item), position: [x, safeY, z] as DirectorVec3 }];
|
||||
return [{ footprint: previsObjectFootprint(item), position: [x, safeY, z] as PrevisVec3 }];
|
||||
});
|
||||
for (const [offsetX, offsetZ] of candidateOffsets()) {
|
||||
const position: DirectorVec3 = [originX + offsetX, safeY, originZ + offsetZ];
|
||||
const position: PrevisVec3 = [originX + offsetX, safeY, originZ + offsetZ];
|
||||
if (!occupied.some((item) => overlaps({ footprint, position }, item, margin))) return position;
|
||||
}
|
||||
return [clearRightOfAll(occupied, footprint, margin, originX), safeY, originZ];
|
||||
@@ -81,7 +81,7 @@ export function resolveDirectorPlacement(input: { object: Pick<DirectorObject, "
|
||||
* 取所有占位的最右边界,再让新对象整体位于其右侧。
|
||||
* 因为 |dx| >= halfX + margin 恒成立,overlaps 的 X 条件必然为假,故一定不相交。
|
||||
*/
|
||||
function clearRightOfAll(occupied: DirectorPlacementCandidate[], footprint: DirectorFootprint, margin: number, originX: number) {
|
||||
function clearRightOfAll(occupied: PrevisPlacementCandidate[], footprint: PrevisFootprint, margin: number, originX: number) {
|
||||
const maxRight = occupied.reduce((rightmost, item) => Math.max(rightmost, item.position[0] + item.footprint.width / 2), Number.NEGATIVE_INFINITY);
|
||||
if (!Number.isFinite(maxRight)) return originX;
|
||||
// 额外加一个 margin 抵消浮点误差,保证严格大于 overlaps 的阈值。
|
||||
@@ -89,24 +89,24 @@ function clearRightOfAll(occupied: DirectorPlacementCandidate[], footprint: Dire
|
||||
}
|
||||
|
||||
/** 地面锚点:只有 XZ,Y 恒由构造器决定,绝不从这里流入。 */
|
||||
export type DirectorGroundPoint = { x: number; z: number };
|
||||
export type PrevisGroundPoint = { x: number; z: number };
|
||||
|
||||
/**
|
||||
* 新增对象时的空间意图。两个来源都可能缺失(尚未移动过 pointer、上下文不可用)。
|
||||
* 顺序固定:pointer > orbit target > 对象自身默认 XZ。
|
||||
*/
|
||||
export type DirectorPlacementIntent = {
|
||||
pointer: DirectorGroundPoint | null;
|
||||
orbitTarget: DirectorGroundPoint | null;
|
||||
export type PrevisPlacementIntent = {
|
||||
pointer: PrevisGroundPoint | null;
|
||||
orbitTarget: PrevisGroundPoint | null;
|
||||
};
|
||||
|
||||
export const emptyDirectorPlacementIntent: DirectorPlacementIntent = { pointer: null, orbitTarget: null };
|
||||
export const emptyPrevisPlacementIntent: PrevisPlacementIntent = { pointer: null, orbitTarget: null };
|
||||
|
||||
/**
|
||||
* 归一化任意数值来源为合法地面点:非有限(NaN / ±Infinity)一律判为不可用。
|
||||
* 射线与地面平行、相机在平面内、上下文半失效时都会产出这类值,必须在入口挡掉。
|
||||
*/
|
||||
export function finiteDirectorGroundPoint(x: unknown, z: unknown): DirectorGroundPoint | null {
|
||||
export function finitePrevisGroundPoint(x: unknown, z: unknown): PrevisGroundPoint | null {
|
||||
if (typeof x !== "number" || typeof z !== "number") return null;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(z)) return null;
|
||||
return { x, z };
|
||||
@@ -121,12 +121,12 @@ export function finiteDirectorGroundPoint(x: unknown, z: unknown): DirectorGroun
|
||||
* (NaN / ±Infinity)。若只用 `??` 判空,非法 pointer 会遮蔽合法 orbitTarget,
|
||||
* 两者都非法时也无法可靠回退 fallback。
|
||||
*/
|
||||
export function resolveDirectorPlacementAnchor(input: { intent: DirectorPlacementIntent | null; fallback: DirectorVec3 }): DirectorVec3 {
|
||||
export function resolvePrevisPlacementAnchor(input: { intent: PrevisPlacementIntent | null; fallback: PrevisVec3 }): PrevisVec3 {
|
||||
const { intent, fallback } = input;
|
||||
const fallbackY = Number.isFinite(fallback?.[1]) ? fallback[1] : 0;
|
||||
const pointer = finiteDirectorGroundPoint(intent?.pointer?.x, intent?.pointer?.z);
|
||||
const orbitTarget = finiteDirectorGroundPoint(intent?.orbitTarget?.x, intent?.orbitTarget?.z);
|
||||
const fallbackPoint = finiteDirectorGroundPoint(fallback?.[0], fallback?.[2]);
|
||||
const pointer = finitePrevisGroundPoint(intent?.pointer?.x, intent?.pointer?.z);
|
||||
const orbitTarget = finitePrevisGroundPoint(intent?.orbitTarget?.x, intent?.orbitTarget?.z);
|
||||
const fallbackPoint = finitePrevisGroundPoint(fallback?.[0], fallback?.[2]);
|
||||
const chosen = pointer ?? orbitTarget ?? fallbackPoint ?? { x: 0, z: 0 };
|
||||
return [chosen.x, fallbackY, chosen.z];
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { PrevisScene, PrevisShot } from "../../../types/previs";
|
||||
|
||||
/** 预览来源的判定结果。image 之外都不渲染 <img>。 */
|
||||
export type PrevisPreviewSource = { kind: "image"; url: string; storageKey?: string } | { kind: "loading" } | { kind: "empty" };
|
||||
|
||||
/** 读取画布节点正文和持久化资源键;刷新后正文可能被清理,但 storageKey 仍可恢复预览。 */
|
||||
export type PrevisNodeContentReader = (nodeId: string | undefined) => string | { content?: string; storageKey?: string } | undefined;
|
||||
|
||||
function usableContent(read: PrevisNodeContentReader, nodeId: string | undefined) {
|
||||
if (!nodeId) return null;
|
||||
const value = read(nodeId);
|
||||
if (!value) return null;
|
||||
const content = (typeof value === "string" ? value : value.content)?.trim() || "";
|
||||
const storageKey = typeof value === "string" ? undefined : value.storageKey;
|
||||
if (!content && !storageKey) return null;
|
||||
return { url: content, storageKey };
|
||||
}
|
||||
|
||||
/** 取当前镜头:优先 metadata 指定,其次场景第一个。 */
|
||||
export function resolvePrevisActiveShot(scene: PrevisScene | null, shotId: string | undefined): PrevisShot | undefined {
|
||||
if (!scene) return undefined;
|
||||
return scene.shots?.find((item) => item.id === shotId) || scene.shots?.[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览来源优先级(严格自上而下):
|
||||
* 1. node.metadata.previsPreviewNodeId 指向的画布节点正文
|
||||
* 2. 当前 PrevisShot.previewNodeId 指向的画布节点正文(补 metadata 丢失)
|
||||
* 3. scene 为 null:场景尚在准备(loading)
|
||||
* 4. 其余:诚实空态
|
||||
* 不改动任何 scene/node 状态,只做读取判定。
|
||||
*/
|
||||
export function resolvePrevisPreviewSource(input: { scene: PrevisScene | null; shot: PrevisShot | undefined; previewNodeId: string | undefined; readNodeContent: PrevisNodeContentReader }): PrevisPreviewSource {
|
||||
const fromMetadata = usableContent(input.readNodeContent, input.previewNodeId);
|
||||
if (fromMetadata) return { kind: "image", ...fromMetadata };
|
||||
const fromShot = usableContent(input.readNodeContent, input.shot?.previewNodeId);
|
||||
if (fromShot) return { kind: "image", ...fromShot };
|
||||
if (!input.scene) return { kind: "loading" };
|
||||
return { kind: "empty" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载失败的门控:记录「失败的那个 URL」而不是布尔标记。
|
||||
* 语义边界:新给出的坏 URL 仍会先渲染一次,浏览器随后才发出 onError —— 无法避免这一帧。
|
||||
* 本门控保证的是:同一个已失败的 URL 不会被反复渲染,且换成另一个 URL 时自动重试,
|
||||
* 无需外部手动清理标记。
|
||||
*/
|
||||
export function gatePrevisPreviewFailure(source: PrevisPreviewSource, failedUrl: string | null): PrevisPreviewSource {
|
||||
if (source.kind !== "image" || !failedUrl) return source;
|
||||
const sourceKey = source.url || source.storageKey || "";
|
||||
return sourceKey === failedUrl ? { kind: "empty" } : source;
|
||||
}
|
||||
|
||||
export type PrevisPreviewRequest = {
|
||||
canvasId?: string;
|
||||
sceneId?: string;
|
||||
shotId?: string;
|
||||
previewRequestId?: string;
|
||||
duration?: number;
|
||||
fps?: number;
|
||||
};
|
||||
|
||||
/** 用后端 request id 去重;旧事件没有 request id 时退回到稳定的请求参数组合。 */
|
||||
export function previsPreviewRequestKey(request: PrevisPreviewRequest) {
|
||||
const requestId = request.previewRequestId?.trim();
|
||||
if (requestId) return `id:${requestId}`;
|
||||
return [
|
||||
"fallback",
|
||||
request.canvasId?.trim() || "",
|
||||
request.sceneId?.trim() || "",
|
||||
request.shotId?.trim() || "",
|
||||
Number.isFinite(request.duration) ? String(request.duration) : "",
|
||||
Number.isFinite(request.fps) ? String(request.fps) : "",
|
||||
].join(":");
|
||||
}
|
||||
|
||||
/** Agent 事件只有命中当前画布、场景和活动镜头时才允许触发浏览器录制。 */
|
||||
export function isPrevisPreviewRequestForWorkbench(input: { request: PrevisPreviewRequest; canvasId?: string; sceneId?: string; activeShotId?: string }) {
|
||||
const requestSceneId = input.request.sceneId?.trim();
|
||||
if (!requestSceneId || requestSceneId !== input.sceneId) return false;
|
||||
const requestCanvasId = input.request.canvasId?.trim();
|
||||
if (input.canvasId && requestCanvasId !== input.canvasId) return false;
|
||||
const requestShotId = input.request.shotId?.trim();
|
||||
if (requestShotId && requestShotId !== input.activeShotId) return false;
|
||||
return true;
|
||||
}
|
||||
+25
-11
@@ -1,7 +1,7 @@
|
||||
import type { DirectorCamera, DirectorObject, DirectorScene, DirectorShot } from "@/types/director";
|
||||
import { directorColorLabel, directorPoseLabel } from "@/lib/canvas/director/director-scene";
|
||||
import type { PrevisCamera, PrevisEnvironment, PrevisObject, PrevisScene, PrevisShot } from "@/types/previs";
|
||||
import { previsActorArchetypeLabel, previsColorLabel, previsPoseLabel } from "@/lib/canvas/previs/previs-scene";
|
||||
|
||||
const shotSizeLabels: Record<DirectorShot["shotSize"], string> = {
|
||||
const shotSizeLabels: Record<PrevisShot["shotSize"], string> = {
|
||||
extreme_wide: "大远景",
|
||||
wide: "远景",
|
||||
full: "全身景",
|
||||
@@ -10,7 +10,7 @@ const shotSizeLabels: Record<DirectorShot["shotSize"], string> = {
|
||||
extreme_close_up: "大特写",
|
||||
};
|
||||
|
||||
const cameraMoveLabels: Record<DirectorShot["cameraMove"], string> = {
|
||||
const cameraMoveLabels: Record<PrevisShot["cameraMove"], string> = {
|
||||
static: "固定机位",
|
||||
push_in: "镜头缓慢推进",
|
||||
pull_out: "镜头缓慢拉远",
|
||||
@@ -23,7 +23,7 @@ const cameraMoveLabels: Record<DirectorShot["cameraMove"], string> = {
|
||||
handheld: "克制的手持摄影运动",
|
||||
};
|
||||
|
||||
export function compileDirectorPrompt(scene: DirectorScene, shot: DirectorShot) {
|
||||
export function compilePrevisPrompt(scene: PrevisScene, shot: PrevisShot) {
|
||||
const camera = scene.cameras.find((item) => item.id === shot.cameraId) || scene.cameras[0];
|
||||
const visibleObjects = scene.objects.filter((item) => item.visible);
|
||||
const actors = visibleObjects.filter((item) => item.kind === "actor" || item.primitive === "character");
|
||||
@@ -31,8 +31,10 @@ export function compileDirectorPrompt(scene: DirectorScene, shot: DirectorShot)
|
||||
shot.prompt.trim(),
|
||||
`镜头设计:${shotSizeLabels[shot.shotSize]},${cameraMoveLabels[shot.cameraMove]},时长 ${formatNumber(shot.duration)} 秒。`,
|
||||
camera ? cameraPrompt(camera) : "",
|
||||
actors.length ? `角色颜色映射:${actors.map((actor) => `${directorColorLabel(actor.color)}人偶(${actor.color})代表${actor.name}`).join(";")}。生成视频时严格按颜色识别角色,不交换人物身份。` : "",
|
||||
environmentPrompt(scene.environment),
|
||||
actors.length ? `角色原型与颜色映射:${actors.map((actor) => `${previsActorArchetypeLabel(actor.archetype)}${actor.name}(${actor.color})`).join(";")}。生成视频时保持每个角色的体型、轮廓和身份稳定。` : "",
|
||||
visibleObjects.length ? `空间调度:${visibleObjects.map(objectPrompt).join(";")}。` : "",
|
||||
assetReferencePrompt(scene),
|
||||
`灯光:${scene.lights.map((light) => `${light.name}${formatNumber(light.intensity)}强度${light.color}`).join(",")}。`,
|
||||
"保持角色、道具、空间方向、光线方向和镜头轴线连续,遵循真实摄影机透视与物理遮挡。",
|
||||
]
|
||||
@@ -40,7 +42,18 @@ export function compileDirectorPrompt(scene: DirectorScene, shot: DirectorShot)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function cameraPrompt(camera: DirectorCamera) {
|
||||
function environmentPrompt(environment?: PrevisEnvironment) {
|
||||
if (!environment || environment.mode !== "panorama" || !environment.url) return "背景环境:使用工作台灰色预览背景。";
|
||||
return `背景环境:使用已绑定的 360° 全景图作为空间背景,保持全景图的水平朝向与镜头旋转连续;白膜角色只参考站位、比例、动作和遮挡,不把全景图拉伸成普通平面。`;
|
||||
}
|
||||
|
||||
function assetReferencePrompt(scene: PrevisScene) {
|
||||
const assets = scene.objects.filter((object) => object.visible && (object.sourceNodeId || object.assetId));
|
||||
if (!assets.length) return "";
|
||||
return `资产引用:${assets.map((object) => `${object.name}${object.sourceNodeId ? `(画布图片节点 ${object.sourceNodeId})` : `(3D 素材 ${object.assetId})`}`).join(";")}。生成时优先参考这些已绑定资产,并保持其主体身份、构图关系和空间方向。`;
|
||||
}
|
||||
|
||||
function cameraPrompt(camera: PrevisCamera) {
|
||||
const [x, y, z] = camera.transform.position;
|
||||
const [tx, ty, tz] = camera.target;
|
||||
const height = y < ty - 0.4 ? "低机位" : y > ty + 1.2 ? "高机位" : "平视机位";
|
||||
@@ -49,11 +62,12 @@ function cameraPrompt(camera: DirectorCamera) {
|
||||
return `摄影机:${formatNumber(camera.focalLength)}mm 焦段,f/${formatNumber(camera.aperture)} 光圈,焦点距离 ${formatNumber(camera.focusDistance)} 米,${height},位于${side},机位距离约 ${formatNumber(distance)} 米,焦点指向 (${formatNumber(tx)}, ${formatNumber(ty)}, ${formatNumber(tz)}),景深遵循真实镜头光学。`;
|
||||
}
|
||||
|
||||
function objectPrompt(object: DirectorObject) {
|
||||
function objectPrompt(object: PrevisObject) {
|
||||
const [x, y, z] = object.transform.position;
|
||||
const pose = object.pose ? `,姿势${directorPoseLabel(object.pose)}` : "";
|
||||
const color = object.kind === "actor" || object.primitive === "character" ? `,${directorColorLabel(object.color)}参考人偶` : "";
|
||||
return `${object.name}${color}位于 (${formatNumber(x)}, ${formatNumber(y)}, ${formatNumber(z)})${pose}`;
|
||||
const pose = object.pose ? `,姿势${previsPoseLabel(object.pose)}` : "";
|
||||
const archetype = object.kind === "actor" || object.primitive === "character" ? `,角色原型${previsActorArchetypeLabel(object.archetype)}` : "";
|
||||
const color = object.kind === "actor" || object.primitive === "character" ? `,${previsColorLabel(object.color)}参考色` : "";
|
||||
return `${object.name}${archetype}${color}位于 (${formatNumber(x)}, ${formatNumber(y)}, ${formatNumber(z)})${pose}`;
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
+24
-24
@@ -1,26 +1,26 @@
|
||||
export type DirectorLoadPhase = "loading" | "ready" | "error";
|
||||
export type PrevisLoadPhase = "loading" | "ready" | "error";
|
||||
|
||||
export type DirectorLoadState = {
|
||||
phase: DirectorLoadPhase;
|
||||
export type PrevisLoadState = {
|
||||
phase: PrevisLoadPhase;
|
||||
/** 每次 retry 递增;晚到的旧 generation 回调必须被忽略。 */
|
||||
generation: number;
|
||||
};
|
||||
|
||||
export type DirectorLoadEvent = { type: "start" } | { type: "loaded"; generation: number } | { type: "failed"; generation: number } | { type: "retry" };
|
||||
export type PrevisLoadEvent = { type: "start" } | { type: "loaded"; generation: number } | { type: "failed"; generation: number } | { type: "retry" };
|
||||
|
||||
export const directorLoadInitial: DirectorLoadState = { phase: "loading", generation: 0 };
|
||||
export const previsLoadInitial: PrevisLoadState = { phase: "loading", generation: 0 };
|
||||
|
||||
/** 加载状态机:只接受当前 generation 的结果,retry 递增 generation 并回到 loading。 */
|
||||
export function reduceDirectorLoad(state: DirectorLoadState, event: DirectorLoadEvent): DirectorLoadState {
|
||||
export function reducePrevisLoad(state: PrevisLoadState, event: PrevisLoadEvent): PrevisLoadState {
|
||||
if (event.type === "start") return { phase: "loading", generation: state.generation };
|
||||
if (event.type === "retry") return { phase: "loading", generation: state.generation + 1 };
|
||||
if (event.generation !== state.generation) return state;
|
||||
return { phase: event.type === "loaded" ? "ready" : "error", generation: state.generation };
|
||||
}
|
||||
|
||||
export type DirectorContextState = "ok" | "lost";
|
||||
export type PrevisContextState = "ok" | "lost";
|
||||
|
||||
export type DirectorCanvasTarget = {
|
||||
export type PrevisCanvasTarget = {
|
||||
addEventListener: (type: string, listener: (event: Event) => void) => void;
|
||||
removeEventListener: (type: string, listener: (event: Event) => void) => void;
|
||||
};
|
||||
@@ -29,7 +29,7 @@ export type DirectorCanvasTarget = {
|
||||
* WebGL 上下文监听:lost 必须 preventDefault,否则浏览器不会补发 restored。
|
||||
* 返回 disposer,随 renderer / 重试 / unmount 精确摘除。
|
||||
*/
|
||||
export function installDirectorContextListeners(target: DirectorCanvasTarget, handlers: { onLost: () => void; onRestored: () => void }) {
|
||||
export function installPrevisContextListeners(target: PrevisCanvasTarget, handlers: { onLost: () => void; onRestored: () => void }) {
|
||||
const onLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
handlers.onLost();
|
||||
@@ -44,22 +44,22 @@ export function installDirectorContextListeners(target: DirectorCanvasTarget, ha
|
||||
}
|
||||
|
||||
/** 加载失败登记表:对象 id -> 该对象自己的 retry。 */
|
||||
export type DirectorFailedLoads = Record<string, () => void>;
|
||||
export type PrevisFailedLoads = Record<string, () => void>;
|
||||
|
||||
/** 登记信号:除加载态外,还包含「对象已卸载」这一必须注销的情形。 */
|
||||
export type DirectorLoadSignal = DirectorLoadPhase | "unmounted";
|
||||
export type PrevisLoadSignal = PrevisLoadPhase | "unmounted";
|
||||
|
||||
/**
|
||||
* 按信号登记/注销失败对象。ready/loading/unmounted 都必须移除,
|
||||
* 否则 retry 回到 loading、或对象被删除后,通知会一直挂着陈旧条目。
|
||||
*/
|
||||
export function upsertDirectorFailedLoad(current: DirectorFailedLoads, id: string, signal: DirectorLoadSignal, retry: () => void): DirectorFailedLoads {
|
||||
export function upsertPrevisFailedLoad(current: PrevisFailedLoads, id: string, signal: PrevisLoadSignal, retry: () => void): PrevisFailedLoads {
|
||||
if (signal === "error") return current[id] === retry ? current : { ...current, [id]: retry };
|
||||
return removeDirectorFailedLoad(current, id);
|
||||
return removePrevisFailedLoad(current, id);
|
||||
}
|
||||
|
||||
/** 对象卸载(删除、隐藏、换 URL/kind、Canvas 重建)必须注销自己,不能残留不存在对象的 Retry。 */
|
||||
export function removeDirectorFailedLoad(current: DirectorFailedLoads, id: string): DirectorFailedLoads {
|
||||
export function removePrevisFailedLoad(current: PrevisFailedLoads, id: string): PrevisFailedLoads {
|
||||
if (!(id in current)) return current;
|
||||
const next = { ...current };
|
||||
delete next[id];
|
||||
@@ -70,30 +70,30 @@ export function removeDirectorFailedLoad(current: DirectorFailedLoads, id: strin
|
||||
* WebGL capture 可用性:上下文丢失期间不得继续使用失效 renderer,
|
||||
* 恢复后必须重新登记 capture context 才算可用。
|
||||
*/
|
||||
export type DirectorCaptureState = { registered: boolean; contextLost: boolean };
|
||||
export type PrevisCaptureState = { registered: boolean; contextLost: boolean };
|
||||
|
||||
export const directorCaptureInitial: DirectorCaptureState = { registered: false, contextLost: false };
|
||||
export const previsCaptureInitial: PrevisCaptureState = { registered: false, contextLost: false };
|
||||
|
||||
export function reduceDirectorCapture(state: DirectorCaptureState, event: "register" | "lost" | "restored" | "reset"): DirectorCaptureState {
|
||||
export function reducePrevisCapture(state: PrevisCaptureState, event: "register" | "lost" | "restored" | "reset"): PrevisCaptureState {
|
||||
if (event === "register") return { registered: true, contextLost: false };
|
||||
if (event === "lost") return { ...state, contextLost: true };
|
||||
// restored 只清丢失标记;capture context 需由 renderer 重新登记后才可用。
|
||||
if (event === "restored") return { registered: false, contextLost: false };
|
||||
return directorCaptureInitial;
|
||||
return previsCaptureInitial;
|
||||
}
|
||||
|
||||
export function directorCaptureUsable(state: DirectorCaptureState) {
|
||||
export function previsCaptureUsable(state: PrevisCaptureState) {
|
||||
return state.registered && !state.contextLost;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文恢复的完整序列,生产 handler 与测试必须共用同一个实现。
|
||||
* reduceDirectorCapture("restored") 会把 registered 置回 false,
|
||||
* reducePrevisCapture("restored") 会把 registered 置回 false,
|
||||
* 因此恢复后必须用当前 renderer 重新登记 capture context,否则
|
||||
* capture/record/readCameraTransform 会永久不可用(只有手动重建 Canvas 才恢复)。
|
||||
* 顺序:先复位可用性 -> 再登记新 context -> 最后请求重绘。
|
||||
*/
|
||||
export function restoreDirectorCapture<TContext>(input: { readContext: () => TContext; onAvailability: (event: "restored") => void; onRegister: (context: TContext) => void; invalidate?: () => void }) {
|
||||
export function restorePrevisCapture<TContext>(input: { readContext: () => TContext; onAvailability: (event: "restored") => void; onRegister: (context: TContext) => void; invalidate?: () => void }) {
|
||||
input.onAvailability("restored");
|
||||
input.onRegister(input.readContext());
|
||||
input.invalidate?.();
|
||||
@@ -106,7 +106,7 @@ export function restoreDirectorCapture<TContext>(input: { readContext: () => TCo
|
||||
* 否则 ErrorBoundary 显示恢复页后「应用到镜头」仍会拿到死 renderer。
|
||||
* reset 后仍可由新的 renderer 重新 register,不影响重试路径。
|
||||
*/
|
||||
export function releaseDirectorCapture(input: { clearContext: () => void; onAvailability: (event: "reset") => void }) {
|
||||
export function releasePrevisCapture(input: { clearContext: () => void; onAvailability: (event: "reset") => void }) {
|
||||
input.clearContext();
|
||||
input.onAvailability("reset");
|
||||
}
|
||||
@@ -115,11 +115,11 @@ export function releaseDirectorCapture(input: { clearContext: () => void; onAvai
|
||||
* 加载身份:generation + 解析输入。展示状态必须携带它,
|
||||
* 这样 prop/retry 变化的第一次 render 就能屏蔽旧资源 —— 不能依赖 effect 之后的 render 才安全。
|
||||
*/
|
||||
export function directorLoadIdentity(input: { generation: number; url?: string | null; storageKey?: string | null; kind?: string | null }) {
|
||||
export function previsLoadIdentity(input: { generation: number; url?: string | null; storageKey?: string | null; kind?: string | null }) {
|
||||
return [input.generation, input.url ?? "", input.storageKey ?? "", input.kind ?? ""].join("|");
|
||||
}
|
||||
|
||||
/** render 阶段的守卫:identity 不匹配就当作「还没有可展示资源」。 */
|
||||
export function resolveDirectorDisplay<TValue>(state: { identity: string; value: TValue } | null, identity: string): TValue | null {
|
||||
export function resolvePrevisDisplay<TValue>(state: { identity: string; value: TValue } | null, identity: string): TValue | null {
|
||||
return state && state.identity === identity ? state.value : null;
|
||||
}
|
||||
+27
-27
@@ -1,12 +1,12 @@
|
||||
import { resolveDirectorPlacement } from "@/lib/canvas/director/director-placement";
|
||||
import { createDirectorModel, touchDirectorScene } from "@/lib/canvas/director/director-scene";
|
||||
import type { DirectorObject, DirectorScene, DirectorVec3 } from "@/types/director";
|
||||
import { resolvePrevisPlacement } from "@/lib/canvas/previs/previs-placement";
|
||||
import { createPrevisModel, touchPrevisScene } from "@/lib/canvas/previs/previs-scene";
|
||||
import type { PrevisObject, PrevisScene, PrevisVec3 } from "@/types/previs";
|
||||
|
||||
/**
|
||||
* P0 复现用确定性 fixture。
|
||||
*
|
||||
* 硬约束:fixture 自身绝不引用网络资产。
|
||||
* - 不使用 createDirectorScene(它会放入依赖远端 GLB 的默认演员);
|
||||
* - 不使用 createPrevisScene(它会放入依赖远端 GLB 的默认演员);
|
||||
* - 所有对象都是 primitive,不带 url / storageKey / assetId;
|
||||
* - 所有 id 与时间戳都是字面量,因此每次构造完全一致(可直接断言)。
|
||||
*
|
||||
@@ -20,7 +20,7 @@ const FIXTURE_SCENE_ID = "repro-scene-1";
|
||||
const FIXTURE_SHOT_ID = "repro-shot-1";
|
||||
const FIXTURE_CAMERA_ID = "repro-camera-1";
|
||||
|
||||
function fixtureObject(input: { id: string; name: string; primitive: "box" | "sphere" | "cylinder" | "plane"; position: DirectorVec3; color: string }): DirectorObject {
|
||||
function fixtureObject(input: { id: string; name: string; primitive: "box" | "sphere" | "cylinder" | "plane"; position: PrevisVec3; color: string }): PrevisObject {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
@@ -39,7 +39,7 @@ function fixtureObject(input: { id: string; name: string; primitive: "box" | "sp
|
||||
* 构造 fixture 场景。同一 seed 下结构完全确定,不含任何远端资产。
|
||||
* 返回全新对象,调用方可以自由改写而不影响后续构造。
|
||||
*/
|
||||
export function createDirectorReproScene(): DirectorScene {
|
||||
export function createPrevisReproScene(): PrevisScene {
|
||||
return {
|
||||
id: FIXTURE_SCENE_ID,
|
||||
version: 1,
|
||||
@@ -97,12 +97,12 @@ export function createDirectorReproScene(): DirectorScene {
|
||||
}
|
||||
|
||||
/** fixture 是否完全不依赖网络资产:任何 url/storageKey/assetId 都算违约。 */
|
||||
export function directorReproSceneIsOffline(scene: DirectorScene): boolean {
|
||||
export function previsReproSceneIsOffline(scene: PrevisScene): boolean {
|
||||
return scene.objects.every((object) => !object.url && !object.storageKey && !object.assetId);
|
||||
}
|
||||
|
||||
/** P0 手工复现矩阵。页面据此渲染清单,Stage D 可以逐条执行。 */
|
||||
export type DirectorReproCase = {
|
||||
export type PrevisReproCase = {
|
||||
id: string;
|
||||
group: string;
|
||||
title: string;
|
||||
@@ -110,7 +110,7 @@ export type DirectorReproCase = {
|
||||
expected: string;
|
||||
};
|
||||
|
||||
export const DIRECTOR_REPRO_MATRIX: readonly DirectorReproCase[] = [
|
||||
export const PREVIS_REPRO_MATRIX: readonly PrevisReproCase[] = [
|
||||
{ id: "empty-click", group: "选择语义", title: "空白单击", steps: "在视口空白处单击。", expected: "取消选择,不产生历史记录,状态栏不变为未保存。" },
|
||||
{ id: "right-click", group: "选择语义", title: "右击", steps: "在对象上右击,再在空白处右击。", expected: "不改变选择、不开始手势、不写入历史。" },
|
||||
{ id: "switch-object", group: "选择语义", title: "切换对象", steps: "拖动对象 A 后不松手切换选中 B(或先松手再选 B)。", expected: "旧手势先被终结,B 不消费 A 的 base。" },
|
||||
@@ -123,16 +123,16 @@ export const DIRECTOR_REPRO_MATRIX: readonly DirectorReproCase[] = [
|
||||
{ id: "autokey-off", group: "时间轴", title: "Auto Key 关", steps: "关闭 Auto Key,在非 0 秒拖动对象。", expected: "只改基础 transform,不产生关键帧。" },
|
||||
{ id: "add-move-delete-undo", group: "编辑链路", title: "连续新增/移动/删除/Undo", steps: "连续新增三个对象,各移动一次,删除中间一个,然后连续 Undo 到初始。", expected: "每步都可逆,Undo 后位置与选择状态自洽,不出现重叠或丢失。" },
|
||||
{ id: "delete-while-loading", group: "模型", title: "模型加载中删除", steps: "点击「注入本地模型」,立即打开工作台,在模型仍在加载时于左侧列表删除它。", expected: "晚到的加载结果被丢弃并释放,不留孤儿资源、不报错。" },
|
||||
{ id: "model-load-failed", group: "模型", title: "模型加载失败", steps: "点击「注入缺失模型」,打开工作台等待加载失败。", expected: "退回占位人偶,右上出现可操作「重试加载」,诊断记录 DIRECTOR_MODEL_LOAD_FAILED。" },
|
||||
{ id: "model-load-failed", group: "模型", title: "模型加载失败", steps: "点击「注入缺失模型」,打开工作台等待加载失败。", expected: "退回占位人偶,右上出现可操作「重试加载」,诊断记录 PREVIS_MODEL_LOAD_FAILED。" },
|
||||
{ id: "save-failed", group: "保存", title: "保存失败", steps: "让保存 flush 抛错后观察头部状态与重试。", expected: "状态为错误,草稿保留时提示可恢复,重试可再次尝试。" },
|
||||
{ id: "close-recovery", group: "保存", title: "关闭与草稿恢复", steps: "保存失败后关闭;重开同一场景。", expected: "关闭走 prepareClose 决策;重开时提示恢复或放弃,放弃后不再重复提示。" },
|
||||
];
|
||||
|
||||
/** 注入变体:本地可达 glTF,或确定性不可达地址。 */
|
||||
export type DirectorReproModelVariant = "local" | "missing";
|
||||
export type PrevisReproModelVariant = "local" | "missing";
|
||||
|
||||
/** 固定 id:重复注入必须替换同一对象,而不是不断堆叠新对象。 */
|
||||
export const DIRECTOR_REPRO_MODEL_IDS: Record<DirectorReproModelVariant, string> = {
|
||||
export const PREVIS_REPRO_MODEL_IDS: Record<PrevisReproModelVariant, string> = {
|
||||
local: "repro-model-local",
|
||||
missing: "repro-model-missing",
|
||||
};
|
||||
@@ -140,22 +140,22 @@ export const DIRECTOR_REPRO_MODEL_IDS: Record<DirectorReproModelVariant, string>
|
||||
/** 同源本地资产,不依赖外网可达性。
|
||||
* 手写 glTF 2.0:内嵌 base64 buffer、无纹理、无压缩扩展,
|
||||
* 因此不需要 KTX2Loader / DRACOLoader 也能被 GLTFLoader 直接解析。 */
|
||||
export const DIRECTOR_REPRO_LOCAL_MODEL_URL = "/canvas/models/director-repro-triangle.gltf";
|
||||
export const PREVIS_REPRO_LOCAL_MODEL_URL = "/canvas/models/previs-repro-triangle.gltf";
|
||||
/** 同源但确定不存在:稳定触发加载失败路径,不依赖外网可达性。 */
|
||||
export const DIRECTOR_REPRO_MISSING_MODEL_URL = "/__director-repro-missing.glb";
|
||||
export const PREVIS_REPRO_MISSING_MODEL_URL = "/__previs-repro-missing.glb";
|
||||
|
||||
const MODEL_URLS: Record<DirectorReproModelVariant, string> = {
|
||||
local: DIRECTOR_REPRO_LOCAL_MODEL_URL,
|
||||
missing: DIRECTOR_REPRO_MISSING_MODEL_URL,
|
||||
const MODEL_URLS: Record<PrevisReproModelVariant, string> = {
|
||||
local: PREVIS_REPRO_LOCAL_MODEL_URL,
|
||||
missing: PREVIS_REPRO_MISSING_MODEL_URL,
|
||||
};
|
||||
|
||||
/** local 是 JSON 形态 glTF,missing 仍按二进制声明,各自与 URL 后缀一致。 */
|
||||
const MODEL_MIME_TYPES: Record<DirectorReproModelVariant, string> = {
|
||||
const MODEL_MIME_TYPES: Record<PrevisReproModelVariant, string> = {
|
||||
local: "model/gltf+json",
|
||||
missing: "model/gltf-binary",
|
||||
};
|
||||
|
||||
const MODEL_NAMES: Record<DirectorReproModelVariant, string> = {
|
||||
const MODEL_NAMES: Record<PrevisReproModelVariant, string> = {
|
||||
local: "本地模型 repro triangle",
|
||||
missing: "缺失模型(预期失败)",
|
||||
};
|
||||
@@ -165,26 +165,26 @@ const MODEL_NAMES: Record<DirectorReproModelVariant, string> = {
|
||||
*
|
||||
* 语义要点:
|
||||
* - 固定 id,重复注入先移除同 id 再重算位置,因此不会堆叠也不会自我碰撞;
|
||||
* - 只改 XZ,Y 严格保留 createDirectorModel 给出的 0(模型贴地);
|
||||
* - 位置经 resolveDirectorPlacement 避让现有对象;
|
||||
* - 只改 XZ,Y 严格保留 createPrevisModel 给出的 0(模型贴地);
|
||||
* - 位置经 resolvePrevisPlacement 避让现有对象;
|
||||
* - 初始 fixture 本身不含模型,注入是显式动作,不污染离线基线。
|
||||
*/
|
||||
export function injectDirectorReproModel(scene: DirectorScene, variant: DirectorReproModelVariant): DirectorScene {
|
||||
const id = DIRECTOR_REPRO_MODEL_IDS[variant];
|
||||
const base = createDirectorModel({
|
||||
export function injectPrevisReproModel(scene: PrevisScene, variant: PrevisReproModelVariant): PrevisScene {
|
||||
const id = PREVIS_REPRO_MODEL_IDS[variant];
|
||||
const base = createPrevisModel({
|
||||
name: MODEL_NAMES[variant],
|
||||
assetId: id,
|
||||
storageKey: undefined,
|
||||
url: MODEL_URLS[variant],
|
||||
mimeType: MODEL_MIME_TYPES[variant],
|
||||
});
|
||||
const model: DirectorObject = { ...base, id };
|
||||
const model: PrevisObject = { ...base, id };
|
||||
|
||||
// 先剔除同 id 旧对象,避免重复点击时新位置去避让自己。
|
||||
const others = scene.objects.filter((object) => object.id !== id);
|
||||
const position = resolveDirectorPlacement({ object: model, existing: others });
|
||||
const position = resolvePrevisPlacement({ object: model, existing: others });
|
||||
|
||||
return touchDirectorScene({
|
||||
return touchPrevisScene({
|
||||
...scene,
|
||||
objects: [...others, { ...model, transform: { ...model.transform, position } }],
|
||||
});
|
||||
+11
-11
@@ -6,7 +6,7 @@
|
||||
* WebGL 不可用时必须稳定降级为 { available: false },不能抛错。
|
||||
*/
|
||||
|
||||
export type DirectorReproRuntime = {
|
||||
export type PrevisReproRuntime = {
|
||||
appVersion: string;
|
||||
buildCommit: string;
|
||||
browser: string;
|
||||
@@ -15,7 +15,7 @@ export type DirectorReproRuntime = {
|
||||
devicePixelRatio: number;
|
||||
};
|
||||
|
||||
export type DirectorReproWebgl =
|
||||
export type PrevisReproWebgl =
|
||||
| { available: false; reason: "unsupported" | "context-failed" }
|
||||
| {
|
||||
available: true;
|
||||
@@ -28,9 +28,9 @@ export type DirectorReproWebgl =
|
||||
maxViewportHeight: number;
|
||||
};
|
||||
|
||||
export type DirectorReproSnapshot = {
|
||||
runtime: DirectorReproRuntime;
|
||||
webgl: DirectorReproWebgl;
|
||||
export type PrevisReproSnapshot = {
|
||||
runtime: PrevisReproRuntime;
|
||||
webgl: PrevisReproWebgl;
|
||||
};
|
||||
|
||||
const MAX_TEXT = 160;
|
||||
@@ -67,7 +67,7 @@ function boundedInteger(value: unknown, min: number, max: number): number {
|
||||
return Math.round(boundedNumber(value, min, max));
|
||||
}
|
||||
|
||||
export function readDirectorReproRuntime(): DirectorReproRuntime {
|
||||
export function readPrevisReproRuntime(): PrevisReproRuntime {
|
||||
const nav = typeof navigator === "undefined" ? null : navigator;
|
||||
let timezone = "";
|
||||
try {
|
||||
@@ -101,7 +101,7 @@ type GlLike = {
|
||||
* 从已有上下文投影安全 WebGL 信息。注入 gl 便于测试降级分支。
|
||||
* 任何 getParameter/getExtension 抛错都收敛为 context-failed,不外泄异常。
|
||||
*/
|
||||
export function projectDirectorWebgl(gl: unknown): DirectorReproWebgl {
|
||||
export function projectPrevisWebgl(gl: unknown): PrevisReproWebgl {
|
||||
if (!gl || typeof gl !== "object") return { available: false, reason: "unsupported" };
|
||||
const context = gl as GlLike;
|
||||
if (typeof context.getParameter !== "function") return { available: false, reason: "unsupported" };
|
||||
@@ -145,13 +145,13 @@ export function projectDirectorWebgl(gl: unknown): DirectorReproWebgl {
|
||||
* 一次性 canvas 不释放会挤占真实视口的额度。
|
||||
* 释放发生在快照取好之后,且异常一律吞掉,绝不影响已读到的结果。
|
||||
*/
|
||||
export function probeDirectorWebgl(): DirectorReproWebgl {
|
||||
export function probePrevisWebgl(): PrevisReproWebgl {
|
||||
if (typeof document === "undefined") return { available: false, reason: "unsupported" };
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
|
||||
if (!gl) return { available: false, reason: "unsupported" };
|
||||
const snapshot = projectDirectorWebgl(gl);
|
||||
const snapshot = projectPrevisWebgl(gl);
|
||||
releaseProbeContext(gl);
|
||||
return snapshot;
|
||||
} catch {
|
||||
@@ -173,6 +173,6 @@ export function releaseProbeContext(gl: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function readDirectorReproSnapshot(): DirectorReproSnapshot {
|
||||
return { runtime: readDirectorReproRuntime(), webgl: probeDirectorWebgl() };
|
||||
export function readPrevisReproSnapshot(): PrevisReproSnapshot {
|
||||
return { runtime: readPrevisReproRuntime(), webgl: probePrevisWebgl() };
|
||||
}
|
||||
+14
-14
@@ -32,11 +32,11 @@ function collectTextures(material: DisposableMaterial, sink: Set<Texture>) {
|
||||
TEXTURE_SLOTS.forEach((slot) => {
|
||||
const value = material[slot];
|
||||
// 必须真的是 Texture:仅凭「有 dispose」会把 RenderTarget 等误判成纹理并造成错误释放。
|
||||
if (isDirectorTexture(value)) sink.add(value);
|
||||
if (isPrevisTexture(value)) sink.add(value);
|
||||
});
|
||||
}
|
||||
|
||||
function isDirectorTexture(value: unknown): value is Texture {
|
||||
function isPrevisTexture(value: unknown): value is Texture {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Partial<Texture>;
|
||||
return candidate.isTexture === true && typeof candidate.dispose === "function";
|
||||
@@ -48,13 +48,13 @@ function isDirectorTexture(value: unknown): value is Texture {
|
||||
* 因此被采纳(adopt)时绝不能释放 source —— 共享资源随 owned clone 的最终 cleanup 一并释放。
|
||||
* 只有未被采纳的晚到/失效 generation 才需要释放 source,避免孤儿泄漏。
|
||||
*/
|
||||
export function resolveDirectorLoadOwnership(input: { active: boolean; generation: number; currentGeneration: number }) {
|
||||
export function resolvePrevisLoadOwnership(input: { active: boolean; generation: number; currentGeneration: number }) {
|
||||
const adopt = input.active && input.generation === input.currentGeneration;
|
||||
return { adopt, disposeSource: !adopt };
|
||||
}
|
||||
|
||||
/** 释放一组材质及其引用纹理;用于被替换掉的原材质,避免 actor 参考材质造成泄漏。 */
|
||||
export function disposeDirectorMaterials(materials: Array<Material | null | undefined>) {
|
||||
export function disposePrevisMaterials(materials: Array<Material | null | undefined>) {
|
||||
const owned = new Set<DisposableMaterial>();
|
||||
materials.forEach((material) => {
|
||||
if (material && typeof material.dispose === "function") owned.add(material as DisposableMaterial);
|
||||
@@ -92,7 +92,7 @@ function disposeCollected(geometries: Set<{ dispose: () => void }>, materials: S
|
||||
* 共享的 geometry/material/texture 用 Set 去重,保证同一资源只 dispose 一次。
|
||||
* `<primitive object={...}>` 不会代为释放,必须由 owner 显式调用。
|
||||
*/
|
||||
export function disposeDirectorObject3D(root: Object3D | null | undefined) {
|
||||
export function disposePrevisObject3D(root: Object3D | null | undefined) {
|
||||
if (!root) return;
|
||||
const geometries = new Set<{ dispose: () => void }>();
|
||||
const materials = new Set<DisposableMaterial>();
|
||||
@@ -101,12 +101,12 @@ export function disposeDirectorObject3D(root: Object3D | null | undefined) {
|
||||
}
|
||||
|
||||
/** SkeletonHelper 自带 geometry/material,必须单独释放。 */
|
||||
export function disposeDirectorHelper(helper: Object3D | null | undefined) {
|
||||
disposeDirectorObject3D(helper);
|
||||
export function disposePrevisHelper(helper: Object3D | null | undefined) {
|
||||
disposePrevisObject3D(helper);
|
||||
}
|
||||
|
||||
/** mixer 停止全部动作并解除 root 缓存,避免 clip 绑定泄漏。 */
|
||||
export function disposeDirectorMixer(mixer: AnimationMixer | null | undefined, root?: Object3D | null) {
|
||||
export function disposePrevisMixer(mixer: AnimationMixer | null | undefined, root?: Object3D | null) {
|
||||
if (!mixer) return;
|
||||
mixer.stopAllAction();
|
||||
if (root) mixer.uncacheRoot(root as never);
|
||||
@@ -118,8 +118,8 @@ export function disposeDirectorMixer(mixer: AnimationMixer | null | undefined, r
|
||||
* source 的 Object3D 层级本身可被 GC,不需要(也不能)在这里再 dispose 一遍。
|
||||
* model/helper 在同一次去重集合内收集,共享资源恰好 dispose 一次。
|
||||
*/
|
||||
export function disposeDirectorModelResources(input: { model?: Object3D | null; helper?: Object3D | null; mixer?: AnimationMixer | null }) {
|
||||
disposeDirectorMixer(input.mixer, input.model);
|
||||
export function disposePrevisModelResources(input: { model?: Object3D | null; helper?: Object3D | null; mixer?: AnimationMixer | null }) {
|
||||
disposePrevisMixer(input.mixer, input.model);
|
||||
const geometries = new Set<{ dispose: () => void }>();
|
||||
const materials = new Set<DisposableMaterial>();
|
||||
collectObject3DResources(input.model, geometries, materials);
|
||||
@@ -133,11 +133,11 @@ export function disposeDirectorModelResources(input: { model?: Object3D | null;
|
||||
* - clone 已建成:它已持有与 source 共享的 GPU 引用,只释放 clone(+mixer),source 交给 GC。
|
||||
* - clone 尚未建成:本次没有任何 owned 资源,只释放这份未被采纳的 source。
|
||||
*/
|
||||
export function disposeDirectorAdoptionFailure(input: { clone?: Object3D | null; mixer?: AnimationMixer | null; source?: Object3D | null }) {
|
||||
export function disposePrevisAdoptionFailure(input: { clone?: Object3D | null; mixer?: AnimationMixer | null; source?: Object3D | null }) {
|
||||
if (input.clone) {
|
||||
disposeDirectorModelResources({ model: input.clone, mixer: input.mixer });
|
||||
disposePrevisModelResources({ model: input.clone, mixer: input.mixer });
|
||||
return;
|
||||
}
|
||||
disposeDirectorMixer(input.mixer, null);
|
||||
disposeDirectorObject3D(input.source);
|
||||
disposePrevisMixer(input.mixer, null);
|
||||
disposePrevisObject3D(input.source);
|
||||
}
|
||||
+16
-16
@@ -1,13 +1,13 @@
|
||||
import type { DirectorScene } from "@/types/director";
|
||||
import type { DirectorCloseDecision, DirectorDraftEnvelope, DirectorSaveSnapshot, StorageLike } from "@/lib/canvas/director/director-save";
|
||||
import type { PrevisScene } from "@/types/previs";
|
||||
import type { PrevisCloseDecision, PrevisDraftEnvelope, PrevisSaveSnapshot, StorageLike } from "@/lib/canvas/previs/previs-save";
|
||||
|
||||
/**
|
||||
* UI 只需要保存进度,不需要场景本体。
|
||||
* 去掉 scene 后 React state 不必再为「还没有 coordinator」编造一个假场景。
|
||||
*/
|
||||
export type DirectorSaveProgress = Omit<DirectorSaveSnapshot, "scene">;
|
||||
export type PrevisSaveProgress = Omit<PrevisSaveSnapshot, "scene">;
|
||||
|
||||
export const idleDirectorSaveProgress: DirectorSaveProgress = {
|
||||
export const idlePrevisSaveProgress: PrevisSaveProgress = {
|
||||
revision: 0,
|
||||
confirmedRevision: 0,
|
||||
status: "saved",
|
||||
@@ -15,7 +15,7 @@ export const idleDirectorSaveProgress: DirectorSaveProgress = {
|
||||
};
|
||||
|
||||
/** 从完整快照投影出进度:订阅回调必须走它,避免只更新 status 而漏掉确认信息。 */
|
||||
export function directorSaveProgress(snapshot: DirectorSaveSnapshot): DirectorSaveProgress {
|
||||
export function previsSaveProgress(snapshot: PrevisSaveSnapshot): PrevisSaveProgress {
|
||||
return {
|
||||
revision: snapshot.revision,
|
||||
confirmedRevision: snapshot.confirmedRevision,
|
||||
@@ -25,7 +25,7 @@ export function directorSaveProgress(snapshot: DirectorSaveSnapshot): DirectorSa
|
||||
}
|
||||
|
||||
/** 头部状态展示:文案与语气必须由状态唯一决定,组件不得自己拼措辞。 */
|
||||
export type DirectorSaveIndicator = {
|
||||
export type PrevisSaveIndicator = {
|
||||
label: string;
|
||||
tone: "idle" | "pending" | "danger";
|
||||
/** 未确认或错误时提供重试;已保存态没有可重试的东西。 */
|
||||
@@ -33,7 +33,7 @@ export type DirectorSaveIndicator = {
|
||||
busy: boolean;
|
||||
};
|
||||
|
||||
export function describeDirectorSaveStatus(progress: DirectorSaveProgress): DirectorSaveIndicator {
|
||||
export function describePrevisSaveStatus(progress: PrevisSaveProgress): PrevisSaveIndicator {
|
||||
const unconfirmed = progress.confirmedRevision < progress.revision;
|
||||
|
||||
if (progress.status === "error") {
|
||||
@@ -58,7 +58,7 @@ export function describeDirectorSaveStatus(progress: DirectorSaveProgress): Dire
|
||||
* scene.updatedAt 是 canonical 保存时间,candidate.baseUpdatedAt 是草稿写入时的基线;
|
||||
* 基线落后于权威说明远端已保存过更新的内容,这份草稿是陈旧残留。
|
||||
*/
|
||||
export function shouldOfferDirectorDraftRecovery(input: { candidate: DirectorDraftEnvelope | null; authoritativeScene: DirectorScene | null }): boolean {
|
||||
export function shouldOfferPrevisDraftRecovery(input: { candidate: PrevisDraftEnvelope | null; authoritativeScene: PrevisScene | null }): boolean {
|
||||
const { candidate, authoritativeScene } = input;
|
||||
if (!candidate || !authoritativeScene) return false;
|
||||
if (candidate.sceneId !== authoritativeScene.id) return false;
|
||||
@@ -67,9 +67,9 @@ export function shouldOfferDirectorDraftRecovery(input: { candidate: DirectorDra
|
||||
}
|
||||
|
||||
/** 关闭决策的用户可见后果,组件按此分支决定退出/二次确认/留下。 */
|
||||
export type DirectorCloseOutcome = { kind: "close" } | { kind: "confirm-draft-exit"; message: string } | { kind: "blocked"; message: string };
|
||||
export type PrevisCloseOutcome = { kind: "close" } | { kind: "confirm-draft-exit"; message: string } | { kind: "blocked"; message: string };
|
||||
|
||||
export function resolveDirectorCloseOutcome(decision: DirectorCloseDecision): DirectorCloseOutcome {
|
||||
export function resolvePrevisCloseOutcome(decision: PrevisCloseDecision): PrevisCloseOutcome {
|
||||
if (decision === "close") return { kind: "close" };
|
||||
if (decision === "offer-draft-exit") {
|
||||
return {
|
||||
@@ -77,14 +77,14 @@ export function resolveDirectorCloseOutcome(decision: DirectorCloseDecision): Di
|
||||
message: "远端保存失败,但本地草稿已安全保存。下次打开这个镜头可以恢复,确认离开吗?",
|
||||
};
|
||||
}
|
||||
return { kind: "blocked", message: "保存失败且本地草稿未写入,已留在导演台,请重试保存。" };
|
||||
return { kind: "blocked", message: "保存失败且本地草稿未写入,已留在预演台,请重试保存。" };
|
||||
}
|
||||
|
||||
/**
|
||||
* beforeunload 是否要触发浏览器保护:只有「改动未确认」或「错误态且没有安全草稿」才拦。
|
||||
* 注意这里只能同步表达意图,异步 flush 无法阻塞卸载。
|
||||
*/
|
||||
export function shouldBlockDirectorUnload(progress: DirectorSaveProgress): boolean {
|
||||
export function shouldBlockPrevisUnload(progress: PrevisSaveProgress): boolean {
|
||||
if (progress.confirmedRevision < progress.revision) return true;
|
||||
return progress.status === "error" && !progress.draftStored;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function shouldBlockDirectorUnload(progress: DirectorSaveProgress): boole
|
||||
* 读返回 null,写和删都抛,让 coordinator 如实得到 draftStored=false。
|
||||
* 绝不能降级成进程内 Map —— 那会让弹窗谎称「下次可恢复」。
|
||||
*/
|
||||
export const unavailableDirectorDraftStorage: StorageLike = {
|
||||
export const unavailablePrevisDraftStorage: StorageLike = {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("localStorage unavailable");
|
||||
@@ -105,14 +105,14 @@ export const unavailableDirectorDraftStorage: StorageLike = {
|
||||
};
|
||||
|
||||
/** 纯 factory:注入 getter 便于测试不可用分支,不引入额外依赖。 */
|
||||
export function resolveDirectorDraftStorage(readStorage: () => Storage | null | undefined): StorageLike {
|
||||
export function resolvePrevisDraftStorage(readStorage: () => Storage | null | undefined): StorageLike {
|
||||
let local: Storage | null | undefined;
|
||||
try {
|
||||
local = readStorage();
|
||||
} catch {
|
||||
return unavailableDirectorDraftStorage;
|
||||
return unavailablePrevisDraftStorage;
|
||||
}
|
||||
if (!local) return unavailableDirectorDraftStorage;
|
||||
if (!local) return unavailablePrevisDraftStorage;
|
||||
|
||||
return {
|
||||
getItem: (key) => local.getItem(key),
|
||||
+38
-38
@@ -1,20 +1,20 @@
|
||||
import { scopedStorageKey } from "@/lib/user-scope";
|
||||
import type { DirectorScene } from "@/types/director";
|
||||
import type { PrevisScene } from "@/types/previs";
|
||||
|
||||
export type DirectorSaveStatus = "dirty" | "saving" | "saved" | "error";
|
||||
export type DirectorCloseDecision = "close" | "offer-draft-exit" | "stay";
|
||||
export type PrevisSaveStatus = "dirty" | "saving" | "saved" | "error";
|
||||
export type PrevisCloseDecision = "close" | "offer-draft-exit" | "stay";
|
||||
|
||||
export type DirectorDraftEnvelope = {
|
||||
export type PrevisDraftEnvelope = {
|
||||
schemaVersion: 1;
|
||||
sceneId: string;
|
||||
baseUpdatedAt: string;
|
||||
scene: DirectorScene;
|
||||
scene: PrevisScene;
|
||||
revision: number;
|
||||
savedAt: number;
|
||||
};
|
||||
|
||||
export type DirectorFlushRequest = {
|
||||
scene: DirectorScene;
|
||||
export type PrevisFlushRequest = {
|
||||
scene: PrevisScene;
|
||||
revision: number;
|
||||
baseUpdatedAt: string;
|
||||
};
|
||||
@@ -25,22 +25,22 @@ export type StorageLike = {
|
||||
removeItem: (key: string) => void;
|
||||
};
|
||||
|
||||
export type FlushFunction = (request: Readonly<DirectorFlushRequest>) => Promise<void>;
|
||||
export type FlushFunction = (request: Readonly<PrevisFlushRequest>) => Promise<void>;
|
||||
|
||||
export type ScheduleFunction = (callback: () => void, delay: number) => unknown;
|
||||
|
||||
export type CancelScheduleFunction = (handle: unknown) => void;
|
||||
|
||||
export type DirectorSaveSnapshot = {
|
||||
scene: DirectorScene;
|
||||
export type PrevisSaveSnapshot = {
|
||||
scene: PrevisScene;
|
||||
revision: number;
|
||||
confirmedRevision: number;
|
||||
status: DirectorSaveStatus;
|
||||
status: PrevisSaveStatus;
|
||||
draftStored: boolean;
|
||||
};
|
||||
|
||||
export type DirectorSaveCoordinatorOptions = {
|
||||
initialScene: DirectorScene;
|
||||
export type PrevisSaveCoordinatorOptions = {
|
||||
initialScene: PrevisScene;
|
||||
scope: string;
|
||||
storage: StorageLike;
|
||||
flush: FlushFunction;
|
||||
@@ -50,25 +50,25 @@ export type DirectorSaveCoordinatorOptions = {
|
||||
cancelSchedule?: CancelScheduleFunction;
|
||||
};
|
||||
|
||||
export interface DirectorSaveCoordinator {
|
||||
getSnapshot: () => DirectorSaveSnapshot;
|
||||
edit: (scene: DirectorScene) => boolean;
|
||||
export interface PrevisSaveCoordinator {
|
||||
getSnapshot: () => PrevisSaveSnapshot;
|
||||
edit: (scene: PrevisScene) => boolean;
|
||||
flushLatest: () => Promise<boolean>;
|
||||
retry: () => Promise<boolean>;
|
||||
restoreCandidate: () => DirectorDraftEnvelope | null;
|
||||
restoreCandidate: () => PrevisDraftEnvelope | null;
|
||||
discardDraft: () => boolean;
|
||||
explicitRestore: (candidate: unknown) => boolean;
|
||||
prepareClose: () => Promise<DirectorCloseDecision>;
|
||||
prepareClose: () => Promise<PrevisCloseDecision>;
|
||||
handlePageHide: () => Promise<boolean>;
|
||||
onStatusChange: (handler: (status: DirectorSaveStatus) => void) => () => void;
|
||||
onStatusChange: (handler: (status: PrevisSaveStatus) => void) => () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const cloneScene = (scene: DirectorScene): DirectorScene => structuredClone(scene);
|
||||
const cloneScene = (scene: PrevisScene): PrevisScene => structuredClone(scene);
|
||||
|
||||
const isDirectorScene = (value: unknown, expectedSceneId: string): value is DirectorScene => {
|
||||
const isPrevisScene = (value: unknown, expectedSceneId: string): value is PrevisScene => {
|
||||
if (!isRecord(value)) return false;
|
||||
|
||||
return (
|
||||
@@ -89,7 +89,7 @@ const isDirectorScene = (value: unknown, expectedSceneId: string): value is Dire
|
||||
);
|
||||
};
|
||||
|
||||
const validateDraftEnvelope = (candidate: unknown, expectedSceneId: string): DirectorDraftEnvelope | null => {
|
||||
const validateDraftEnvelope = (candidate: unknown, expectedSceneId: string): PrevisDraftEnvelope | null => {
|
||||
if (!isRecord(candidate)) return null;
|
||||
|
||||
if (
|
||||
@@ -102,7 +102,7 @@ const validateDraftEnvelope = (candidate: unknown, expectedSceneId: string): Dir
|
||||
typeof candidate.savedAt !== "number" ||
|
||||
!Number.isFinite(candidate.savedAt) ||
|
||||
candidate.savedAt < 0 ||
|
||||
!isDirectorScene(candidate.scene, expectedSceneId)
|
||||
!isPrevisScene(candidate.scene, expectedSceneId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -117,7 +117,7 @@ const validateDraftEnvelope = (candidate: unknown, expectedSceneId: string): Dir
|
||||
};
|
||||
};
|
||||
|
||||
const parseDraftEnvelope = (raw: string, expectedSceneId: string): DirectorDraftEnvelope | null => {
|
||||
const parseDraftEnvelope = (raw: string, expectedSceneId: string): PrevisDraftEnvelope | null => {
|
||||
try {
|
||||
const candidate: unknown = JSON.parse(raw);
|
||||
return validateDraftEnvelope(candidate, expectedSceneId);
|
||||
@@ -126,31 +126,31 @@ const parseDraftEnvelope = (raw: string, expectedSceneId: string): DirectorDraft
|
||||
}
|
||||
};
|
||||
|
||||
export function createDirectorSaveCoordinator(options: DirectorSaveCoordinatorOptions): DirectorSaveCoordinator {
|
||||
export function createPrevisSaveCoordinator(options: PrevisSaveCoordinatorOptions): PrevisSaveCoordinator {
|
||||
const { initialScene, scope, storage, flush, now = Date.now, debounceMs = 300 } = options;
|
||||
|
||||
const schedule: ScheduleFunction = options.schedule ?? ((callback, delay) => globalThis.setTimeout(callback, delay));
|
||||
const cancelSchedule: CancelScheduleFunction = options.cancelSchedule ?? ((handle) => globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>));
|
||||
|
||||
const fixedSceneId = initialScene.id;
|
||||
const storageKey = scopedStorageKey("director-scene-draft:" + fixedSceneId, scope);
|
||||
const storageKey = scopedStorageKey("previs-scene-draft:" + fixedSceneId, scope);
|
||||
|
||||
let currentScene = cloneScene(initialScene);
|
||||
let currentRevision = 0;
|
||||
let confirmedRevision = 0;
|
||||
let baseUpdatedAt = initialScene.updatedAt;
|
||||
let status: DirectorSaveStatus = "saved";
|
||||
let localDraft: DirectorDraftEnvelope | null = null;
|
||||
let status: PrevisSaveStatus = "saved";
|
||||
let localDraft: PrevisDraftEnvelope | null = null;
|
||||
let draftStored = false;
|
||||
let debounceHandle: unknown | null = null;
|
||||
let inFlightFlush: Promise<boolean> | null = null;
|
||||
let closePromise: Promise<DirectorCloseDecision> | null = null;
|
||||
let closePromise: Promise<PrevisCloseDecision> | null = null;
|
||||
let pageHidePromise: Promise<boolean> | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const subscribers = new Set<(status: DirectorSaveStatus) => void>();
|
||||
const subscribers = new Set<(status: PrevisSaveStatus) => void>();
|
||||
|
||||
const setStatus = (nextStatus: DirectorSaveStatus) => {
|
||||
const setStatus = (nextStatus: PrevisSaveStatus) => {
|
||||
if (disposed || status === nextStatus) return;
|
||||
status = nextStatus;
|
||||
for (const subscriber of subscribers) {
|
||||
@@ -162,7 +162,7 @@ export function createDirectorSaveCoordinator(options: DirectorSaveCoordinatorOp
|
||||
}
|
||||
};
|
||||
|
||||
const getSnapshot = (): DirectorSaveSnapshot => ({
|
||||
const getSnapshot = (): PrevisSaveSnapshot => ({
|
||||
scene: cloneScene(currentScene),
|
||||
revision: currentRevision,
|
||||
confirmedRevision,
|
||||
@@ -177,7 +177,7 @@ export function createDirectorSaveCoordinator(options: DirectorSaveCoordinatorOp
|
||||
};
|
||||
|
||||
const storeCurrentDraft = () => {
|
||||
const envelope: DirectorDraftEnvelope = {
|
||||
const envelope: PrevisDraftEnvelope = {
|
||||
schemaVersion: 1,
|
||||
sceneId: fixedSceneId,
|
||||
baseUpdatedAt,
|
||||
@@ -206,7 +206,7 @@ export function createDirectorSaveCoordinator(options: DirectorSaveCoordinatorOp
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
const edit = (scene: DirectorScene): boolean => {
|
||||
const edit = (scene: PrevisScene): boolean => {
|
||||
if (disposed || scene.id !== fixedSceneId) return false;
|
||||
|
||||
currentScene = cloneScene(scene);
|
||||
@@ -314,7 +314,7 @@ export function createDirectorSaveCoordinator(options: DirectorSaveCoordinatorOp
|
||||
|
||||
const retry = (): Promise<boolean> => flushLatest();
|
||||
|
||||
const restoreCandidate = (): DirectorDraftEnvelope | null => {
|
||||
const restoreCandidate = (): PrevisDraftEnvelope | null => {
|
||||
try {
|
||||
const raw = storage.getItem(storageKey);
|
||||
if (raw === null) return null;
|
||||
@@ -364,14 +364,14 @@ export function createDirectorSaveCoordinator(options: DirectorSaveCoordinatorOp
|
||||
return true;
|
||||
};
|
||||
|
||||
const prepareClose = (): Promise<DirectorCloseDecision> => {
|
||||
const prepareClose = (): Promise<PrevisCloseDecision> => {
|
||||
if (disposed) return Promise.resolve("stay");
|
||||
if (confirmedRevision === currentRevision) {
|
||||
return Promise.resolve("close");
|
||||
}
|
||||
if (closePromise) return closePromise;
|
||||
|
||||
const pending = flushLatest().then<DirectorCloseDecision>((success) => {
|
||||
const pending = flushLatest().then<PrevisCloseDecision>((success) => {
|
||||
if (success) return "close";
|
||||
return draftStored ? "offer-draft-exit" : "stay";
|
||||
});
|
||||
@@ -408,7 +408,7 @@ export function createDirectorSaveCoordinator(options: DirectorSaveCoordinatorOp
|
||||
return pending;
|
||||
};
|
||||
|
||||
const onStatusChange = (handler: (status: DirectorSaveStatus) => void): (() => void) => {
|
||||
const onStatusChange = (handler: (status: PrevisSaveStatus) => void): (() => void) => {
|
||||
if (disposed) return () => undefined;
|
||||
|
||||
subscribers.add(handler);
|
||||
+109
-57
@@ -1,16 +1,50 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { Color, Euler, Quaternion } from "three";
|
||||
|
||||
import type { DirectorBoneKeyframe, DirectorBoneTrack, DirectorCamera, DirectorHumanoidBone, DirectorKeyframe, DirectorKeyframeDeleteTarget, DirectorKeyframeEasing, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director";
|
||||
import type { PrevisActorArchetype, PrevisActorProfile, PrevisAspectRatio, PrevisBoneKeyframe, PrevisBoneTrack, PrevisCamera, PrevisHumanoidBone, PrevisKeyframe, PrevisKeyframeDeleteTarget, PrevisKeyframeEasing, PrevisLight, PrevisObject, PrevisPose, PrevisQuat, PrevisScene, PrevisTransform, PrevisVec3 } from "@/types/previs";
|
||||
|
||||
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;
|
||||
export const PREVIS_DEFAULT_ACTOR_URL = "https://cdn.jsdelivr.net/gh/mrdoob/three.js@r185/examples/models/gltf/Xbot.glb";
|
||||
export const PREVIS_LEGACY_DEFAULT_ACTOR_COLOR = "#f1f3f5";
|
||||
export const PREVIS_ACTOR_COLORS = [
|
||||
"#2f7de1", "#d84949", "#dfae3f", "#34a276", "#8b5cf6", "#e879f9",
|
||||
"#f97316", "#06b6d4", "#84cc16", "#ec4899", "#a78bfa", "#fb7185",
|
||||
"#22d3ee", "#4ade80", "#facc15", "#f472b6", "#818cf8", "#34d399",
|
||||
] as const;
|
||||
|
||||
export const directorIdentityTransform = (position: DirectorVec3 = [0, 0, 0]): DirectorTransform => ({ position, rotation: [0, 0, 0], scale: [1, 1, 1] });
|
||||
export const PREVIS_ACTOR_ARCHETYPES = [
|
||||
{ value: "adult", label: "大人" },
|
||||
{ value: "child", label: "小孩" },
|
||||
{ value: "elderly", label: "老人" },
|
||||
{ value: "man", label: "男人" },
|
||||
{ value: "woman", label: "女人" },
|
||||
{ value: "monster", label: "怪兽" },
|
||||
] as const;
|
||||
|
||||
export function createDirectorScene(title = "未命名场景"): DirectorScene {
|
||||
export function previsActorProfileForArchetype(archetype: PrevisActorArchetype = "adult"): PrevisActorProfile {
|
||||
switch (archetype) {
|
||||
case "child": return { height: 0.72, headRatio: 1.28, shoulderWidth: 0.78, torsoRatio: 0.82, accessory: "none" };
|
||||
case "elderly": return { height: 0.92, headRatio: 1.04, shoulderWidth: 0.9, torsoRatio: 1, accessory: "cane" };
|
||||
case "man": return { height: 1.04, headRatio: 0.94, shoulderWidth: 1.16, torsoRatio: 1, accessory: "none" };
|
||||
case "woman": return { height: 1, headRatio: 1, shoulderWidth: 0.86, torsoRatio: 1.02, accessory: "none" };
|
||||
case "monster": return { height: 1.12, headRatio: 1.18, shoulderWidth: 1.34, torsoRatio: 1.08, accessory: "horns" };
|
||||
default: return { height: 1, headRatio: 1, shoulderWidth: 1, torsoRatio: 1, accessory: "none" };
|
||||
}
|
||||
}
|
||||
|
||||
export function previsActorArchetypeLabel(archetype?: PrevisObject["archetype"]) {
|
||||
return PREVIS_ACTOR_ARCHETYPES.find((item) => item.value === archetype)?.label || "大人";
|
||||
}
|
||||
|
||||
export function resolvePrevisActorColor(color?: string) {
|
||||
const normalized = color?.trim().toLowerCase();
|
||||
return !normalized || normalized === PREVIS_LEGACY_DEFAULT_ACTOR_COLOR ? PREVIS_ACTOR_COLORS[0] : color!;
|
||||
}
|
||||
|
||||
export const previsIdentityTransform = (position: PrevisVec3 = [0, 0, 0]): PrevisTransform => ({ position, rotation: [0, 0, 0], scale: [1, 1, 1] });
|
||||
|
||||
export function createPrevisScene(title = "未命名场景"): PrevisScene {
|
||||
const now = new Date().toISOString();
|
||||
const camera = createDirectorCamera();
|
||||
const camera = createPrevisCamera();
|
||||
const shotId = nanoid();
|
||||
return {
|
||||
id: nanoid(),
|
||||
@@ -18,41 +52,45 @@ export function createDirectorScene(title = "未命名场景"): DirectorScene {
|
||||
title,
|
||||
background: "#d8dde3",
|
||||
environmentIntensity: 0.7,
|
||||
environment: { mode: "color" },
|
||||
gridVisible: true,
|
||||
objects: [createDirectorActor("演员 1", [0, 0, 0])],
|
||||
objects: [createPrevisActor("演员 1", [0, 0, 0])],
|
||||
cameras: [camera],
|
||||
lights: [createDirectorLight("directional", "主光", [4, 6, 4], 2.4), createDirectorLight("directional", "轮廓光", [-4, 3, -2], 1.1), createDirectorLight("ambient", "环境光", [0, 0, 0], 0.65)],
|
||||
shots: [{ id: shotId, name: "镜头 1", cameraId: camera.id, duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" }],
|
||||
lights: [createPrevisLight("directional", "主光", [4, 6, 4], 2.4), createPrevisLight("directional", "轮廓光", [-4, 3, -2], 1.1), createPrevisLight("ambient", "环境光", [0, 0, 0], 0.65)],
|
||||
shots: [{ id: shotId, name: "镜头 1", cameraId: camera.id, duration: 5, fps: 24, aspectRatio: "9:16", shotSize: "medium", cameraMove: "static", prompt: "" }],
|
||||
activeShotId: shotId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectorObject(primitive: DirectorObject["primitive"] = "box", name = "新对象", position: DirectorVec3 = [0, 0.5, 0], color = "#8795a5"): DirectorObject {
|
||||
export function createPrevisObject(primitive: PrevisObject["primitive"] = "box", name = "新对象", position: PrevisVec3 = [0, 0.5, 0], color = "#8795a5"): PrevisObject {
|
||||
return {
|
||||
id: nanoid(),
|
||||
name,
|
||||
kind: "primitive",
|
||||
primitive,
|
||||
transform: directorIdentityTransform(position),
|
||||
transform: previsIdentityTransform(position),
|
||||
color,
|
||||
visible: true,
|
||||
castShadow: true,
|
||||
receiveShadow: true,
|
||||
pose: primitive === "character" ? "stand" : undefined,
|
||||
keyframes: [],
|
||||
motionPath: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectorActor(name = "演员", position: DirectorVec3 = [0, 0, 0], color: string = DIRECTOR_ACTOR_COLORS[0]): DirectorObject {
|
||||
export function createPrevisActor(name = "演员", position: PrevisVec3 = [0, 0, 0], color: string = PREVIS_ACTOR_COLORS[0], archetype: PrevisObject["archetype"] = "adult"): PrevisObject {
|
||||
return {
|
||||
...createDirectorObject("box", name, position, color),
|
||||
...createPrevisObject("box", name, position, color),
|
||||
kind: "actor",
|
||||
primitive: undefined,
|
||||
url: DIRECTOR_DEFAULT_ACTOR_URL,
|
||||
url: PREVIS_DEFAULT_ACTOR_URL,
|
||||
mimeType: "model/gltf-binary",
|
||||
pose: "stand",
|
||||
archetype,
|
||||
actorProfile: previsActorProfileForArchetype(archetype),
|
||||
rig: { status: "unmapped", boneMap: {}, animationNames: [] },
|
||||
motionClips: [],
|
||||
boneOverrides: {},
|
||||
@@ -60,54 +98,68 @@ export function createDirectorActor(name = "演员", position: DirectorVec3 = [0
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectorModel(input: Pick<DirectorObject, "name" | "storageKey" | "url" | "mimeType" | "assetId">): DirectorObject {
|
||||
return { ...createDirectorObject("box", input.name, [0, 0, 0]), ...input, kind: "model", primitive: undefined };
|
||||
export function createPrevisModel(input: Pick<PrevisObject, "name" | "storageKey" | "url" | "mimeType" | "assetId">): PrevisObject {
|
||||
return { ...createPrevisObject("box", input.name, [0, 0, 0]), ...input, kind: "model", primitive: undefined };
|
||||
}
|
||||
|
||||
export function createDirectorBillboard(name: string, url: string, storageKey?: string, sourceNodeId?: string): DirectorObject {
|
||||
return { ...createDirectorObject("plane", name, [0, 1.1, 0], "#ffffff"), kind: "billboard", url, storageKey, sourceNodeId, transform: { position: [0, 1.1, 0], rotation: [0, 0, 0], scale: [1.6, 0.9, 1] } };
|
||||
export function createPrevisBillboard(name: string, url: string, storageKey?: string, sourceNodeId?: string): PrevisObject {
|
||||
return { ...createPrevisObject("plane", name, [0, 1.1, 0], "#ffffff"), kind: "billboard", url, storageKey, sourceNodeId, transform: { position: [0, 1.1, 0], rotation: [0, 0, 0], scale: [1, 1, 1] } };
|
||||
}
|
||||
|
||||
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: [] };
|
||||
export function createPrevisCamera(name = "主摄影机"): PrevisCamera {
|
||||
return { id: nanoid(), name, transform: previsIdentityTransform([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: [], motionPath: undefined };
|
||||
}
|
||||
|
||||
export function resolvePrevisAspectRatio(value?: PrevisAspectRatio) {
|
||||
if (value === "16:9") return 16 / 9;
|
||||
if (value === "2.39:1") return 2.39;
|
||||
return 9 / 16;
|
||||
}
|
||||
|
||||
export function uniquePrevisName(existing: readonly string[], requested: string) {
|
||||
const base = requested.trim() || "对象";
|
||||
if (!existing.includes(base)) return base;
|
||||
let index = 2;
|
||||
while (existing.includes(`${base} ${index}`)) index += 1;
|
||||
return `${base} ${index}`;
|
||||
}
|
||||
|
||||
/** 35mm 全画幅水平视角换算。摄影机检查器与场景模板共用,避免两处各写一份光学。 */
|
||||
export function directorFocalLengthToFov(focalLength: number) {
|
||||
export function previsFocalLengthToFov(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" };
|
||||
export function createPrevisLight(type: PrevisLight["type"], name: string, position: PrevisVec3, intensity = 1): PrevisLight {
|
||||
return { id: nanoid(), name, type, transform: previsIdentityTransform(position), color: "#ffffff", intensity, angle: Math.PI / 4, penumbra: 0.35, castShadow: type !== "ambient" };
|
||||
}
|
||||
|
||||
export function touchDirectorScene(scene: DirectorScene): DirectorScene {
|
||||
export function touchPrevisScene(scene: PrevisScene): PrevisScene {
|
||||
return { ...scene, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
/** 关键帧命中容差:upsert 与 remove 必须同判据,否则会出现「记录能覆盖但删不掉」。 */
|
||||
export const DIRECTOR_KEYFRAME_EPSILON = 0.001;
|
||||
export const PREVIS_KEYFRAME_EPSILON = 0.001;
|
||||
|
||||
export function upsertDirectorKeyframe(keyframes: DirectorKeyframe[], time: number, transform: DirectorTransform) {
|
||||
const current = keyframes.find((item) => Math.abs(item.time - time) < DIRECTOR_KEYFRAME_EPSILON);
|
||||
export function upsertPrevisKeyframe(keyframes: PrevisKeyframe[], time: number, transform: PrevisTransform) {
|
||||
const current = keyframes.find((item) => Math.abs(item.time - time) < PREVIS_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);
|
||||
}
|
||||
|
||||
export function upsertDirectorBoneKeyframe(tracks: DirectorBoneTrack[], bone: DirectorHumanoidBone, time: number, rotation: DirectorQuat) {
|
||||
export function upsertPrevisBoneKeyframe(tracks: PrevisBoneTrack[], bone: PrevisHumanoidBone, time: number, rotation: PrevisQuat) {
|
||||
const track = tracks.find((item) => item.bone === bone);
|
||||
const nextKeyframes = upsertBoneKeyframe(track?.keyframes || [], time, rotation);
|
||||
return track ? tracks.map((item) => item.bone === bone ? { ...item, keyframes: nextKeyframes } : item) : [...tracks, { bone, keyframes: nextKeyframes }];
|
||||
}
|
||||
|
||||
function upsertBoneKeyframe(keyframes: DirectorBoneKeyframe[], time: number, rotation: DirectorQuat) {
|
||||
const current = keyframes.find((item) => Math.abs(item.time - time) < DIRECTOR_KEYFRAME_EPSILON);
|
||||
function upsertBoneKeyframe(keyframes: PrevisBoneKeyframe[], time: number, rotation: PrevisQuat) {
|
||||
const current = keyframes.find((item) => Math.abs(item.time - time) < PREVIS_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[] {
|
||||
export function removePrevisKeyframe(keyframes: PrevisKeyframe[], keyframeId: string): PrevisKeyframe[] {
|
||||
if (!keyframes.some((item) => item.id === keyframeId)) return keyframes;
|
||||
return keyframes.filter((item) => item.id !== keyframeId);
|
||||
}
|
||||
@@ -116,7 +168,7 @@ export function removeDirectorKeyframe(keyframes: DirectorKeyframe[], keyframeId
|
||||
* 按 id 删除某骨骼轨道上的关键帧。
|
||||
* 轨道被删空后整条移除,否则时间轴会留下永远为空的子轨道。
|
||||
*/
|
||||
export function removeDirectorBoneKeyframe(tracks: DirectorBoneTrack[], bone: DirectorHumanoidBone, keyframeId: string): DirectorBoneTrack[] {
|
||||
export function removePrevisBoneKeyframe(tracks: PrevisBoneTrack[], bone: PrevisHumanoidBone, keyframeId: string): PrevisBoneTrack[] {
|
||||
const track = tracks.find((item) => item.bone === bone);
|
||||
if (!track?.keyframes.some((item) => item.id === keyframeId)) return tracks;
|
||||
|
||||
@@ -131,11 +183,11 @@ export function removeDirectorBoneKeyframe(tracks: DirectorBoneTrack[], bone: Di
|
||||
* 未命中(对象/摄影机/骨骼/关键帧任一不存在)时返回同一个 scene 引用,
|
||||
* 调用方据此跳过历史与保存,避免「点了没删掉却多一次修订」。
|
||||
*/
|
||||
export function removeDirectorSceneKeyframe(scene: DirectorScene, target: DirectorKeyframeDeleteTarget): DirectorScene {
|
||||
export function removePrevisSceneKeyframe(scene: PrevisScene, target: PrevisKeyframeDeleteTarget): PrevisScene {
|
||||
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);
|
||||
const keyframes = removePrevisKeyframe(camera.keyframes, target.keyframeId);
|
||||
if (keyframes === camera.keyframes) return scene;
|
||||
return { ...scene, cameras: scene.cameras.map((item) => (item.id === camera.id ? { ...item, keyframes } : item)) };
|
||||
}
|
||||
@@ -144,13 +196,13 @@ export function removeDirectorSceneKeyframe(scene: DirectorScene, target: Direct
|
||||
if (!object) return scene;
|
||||
|
||||
if (target.track === "object-transform") {
|
||||
const keyframes = removeDirectorKeyframe(object.keyframes, target.keyframeId);
|
||||
const keyframes = removePrevisKeyframe(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);
|
||||
const boneTracks = removePrevisBoneKeyframe(tracks, target.bone, target.keyframeId);
|
||||
if (boneTracks === tracks) return scene;
|
||||
return { ...scene, objects: scene.objects.map((item) => (item.id === object.id ? { ...item, boneTracks } : item)) };
|
||||
}
|
||||
@@ -158,8 +210,8 @@ export function removeDirectorSceneKeyframe(scene: DirectorScene, target: Direct
|
||||
/**
|
||||
* 更新一枚关键帧后续区间的缓动。未命中时返回原 scene 引用,调用方据此跳过历史与保存。
|
||||
*/
|
||||
export function setDirectorSceneKeyframeEasing(scene: DirectorScene, target: DirectorKeyframeDeleteTarget, easing: DirectorKeyframeEasing): DirectorScene {
|
||||
const update = <T extends { id: string; easing?: DirectorKeyframeEasing }>(keyframes: T[]) => {
|
||||
export function setPrevisSceneKeyframeEasing(scene: PrevisScene, target: PrevisKeyframeDeleteTarget, easing: PrevisKeyframeEasing): PrevisScene {
|
||||
const update = <T extends { id: string; easing?: PrevisKeyframeEasing }>(keyframes: T[]) => {
|
||||
if (!keyframes.some((item) => item.id === target.keyframeId)) return keyframes;
|
||||
return keyframes.map((item) => item.id === target.keyframeId ? { ...item, easing } : item);
|
||||
};
|
||||
@@ -190,7 +242,7 @@ export function setDirectorSceneKeyframeEasing(scene: DirectorScene, target: Dir
|
||||
}
|
||||
|
||||
/** 缓动属于前一枚关键帧到下一枚关键帧的区间。旧数据未声明时保持线性。 */
|
||||
export function resolveDirectorKeyframeProgress(progress: number, easing: DirectorKeyframeEasing = "linear") {
|
||||
export function resolvePrevisKeyframeProgress(progress: number, easing: PrevisKeyframeEasing = "linear") {
|
||||
const clamped = Math.max(0, Math.min(1, progress));
|
||||
if (easing === "step") return 0;
|
||||
if (easing === "smooth") return clamped * clamped * (3 - 2 * clamped);
|
||||
@@ -198,12 +250,12 @@ export function resolveDirectorKeyframeProgress(progress: number, easing: Direct
|
||||
}
|
||||
|
||||
/** 轨迹渲染只接受有限时间与位置;坏数据不得进入 Three 几何体。 */
|
||||
export function finiteDirectorTransformKeyframes(keyframes: DirectorKeyframe[]) {
|
||||
export function finitePrevisTransformKeyframes(keyframes: PrevisKeyframe[]) {
|
||||
return keyframes.filter((keyframe) => [keyframe.time, ...keyframe.transform.position].every(Number.isFinite));
|
||||
}
|
||||
|
||||
/** 按时间顺序累计 Transform 关键帧路径长度;非法时间或坐标段忽略,不污染界面统计。 */
|
||||
export function directorTransformPathLength(keyframes: DirectorKeyframe[]) {
|
||||
export function previsTransformPathLength(keyframes: PrevisKeyframe[]) {
|
||||
const sorted = keyframes.toSorted((left, right) => left.time - right.time);
|
||||
let length = 0;
|
||||
for (let index = 1; index < sorted.length; index += 1) {
|
||||
@@ -217,30 +269,30 @@ export function directorTransformPathLength(keyframes: DirectorKeyframe[]) {
|
||||
return length;
|
||||
}
|
||||
|
||||
export function interpolateDirectorTransform(base: DirectorTransform, keyframes: DirectorKeyframe[], time: number): DirectorTransform {
|
||||
export function interpolatePrevisTransform(base: PrevisTransform, keyframes: PrevisKeyframe[], time: number): PrevisTransform {
|
||||
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 = resolveDirectorKeyframeProgress((time - previous.time) / Math.max(next.time - previous.time, DIRECTOR_KEYFRAME_EPSILON), previous.easing);
|
||||
const progress = resolvePrevisKeyframeProgress((time - previous.time) / Math.max(next.time - previous.time, PREVIS_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),
|
||||
rotation: new Euler().setFromQuaternion(rotation).toArray().slice(0, 3) as DirectorVec3,
|
||||
rotation: new Euler().setFromQuaternion(rotation).toArray().slice(0, 3) as PrevisVec3,
|
||||
scale: lerpVec3(previous.transform.scale, next.transform.scale, progress),
|
||||
};
|
||||
}
|
||||
|
||||
export function interpolateDirectorBoneRotation(base: DirectorQuat, keyframes: DirectorBoneKeyframe[], time: number): DirectorQuat {
|
||||
export function interpolatePrevisBoneRotation(base: PrevisQuat, keyframes: PrevisBoneKeyframe[], time: number): PrevisQuat {
|
||||
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.rotation;
|
||||
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;
|
||||
const progress = resolvePrevisKeyframeProgress((time - previous.time) / Math.max(next.time - previous.time, PREVIS_KEYFRAME_EPSILON), previous.easing);
|
||||
return new Quaternion(...previous.rotation).slerp(new Quaternion(...next.rotation), progress).toArray() as PrevisQuat;
|
||||
}
|
||||
|
||||
export function directorBoneLabel(bone: string) {
|
||||
export function previsBoneLabel(bone: string) {
|
||||
return ({
|
||||
hips: "骨盆", spine: "脊柱", chest: "胸腔", neck: "颈部", head: "头部",
|
||||
leftShoulder: "左肩", leftUpperArm: "左上臂", leftLowerArm: "左前臂", leftHand: "左手",
|
||||
@@ -260,14 +312,14 @@ export function directorBoneLabel(bone: string) {
|
||||
} as Record<string, string>)[bone] || bone;
|
||||
}
|
||||
|
||||
export function directorPoseLabel(pose: DirectorPose) {
|
||||
return ({ neutral: "自然", stand: "站立", t_pose: "T 型", walk: "行走", run: "跑步", sit: "坐姿", squat: "蹲下", kneel_single: "单膝跪", kneel_double: "双膝跪", hands_hips: "叉腰", lean: "倚靠", bow: "鞠躬", think: "思考", fight: "格斗", kick: "踢球", throw: "投掷", push: "推进", wave: "招手", reach: "伸手", arms_crossed: "抱臂", phone: "看手机" } as Record<DirectorPose, string>)[pose];
|
||||
export function previsPoseLabel(pose: PrevisPose) {
|
||||
return ({ neutral: "自然", stand: "站立", t_pose: "T 型", walk: "行走", run: "跑步", sit: "坐姿", squat: "蹲下", kneel_single: "单膝跪", kneel_double: "双膝跪", hands_hips: "叉腰", lean: "倚靠", bow: "鞠躬", think: "思考", fight: "格斗", kick: "踢球", throw: "投掷", push: "推进", wave: "招手", reach: "伸手", arms_crossed: "抱臂", phone: "看手机" } as Record<PrevisPose, string>)[pose];
|
||||
}
|
||||
|
||||
export function directorPoseBoneDeltas(pose: DirectorPose): Partial<Record<DirectorHumanoidBone, DirectorQuat>> {
|
||||
export function previsPoseBoneDeltas(pose: PrevisPose): Partial<Record<PrevisHumanoidBone, PrevisQuat>> {
|
||||
// Soldier 的左右上臂局部 Z 轴方向一致,正向旋转才会把两侧手臂从 T Pose 放下。
|
||||
const armsDown = { leftUpperArm: poseQuaternion(0, 0, 1.28), rightUpperArm: poseQuaternion(0, 0, 1.28) };
|
||||
const poses: Record<DirectorPose, Partial<Record<DirectorHumanoidBone, DirectorQuat>>> = {
|
||||
const poses: Record<PrevisPose, Partial<Record<PrevisHumanoidBone, PrevisQuat>>> = {
|
||||
neutral: armsDown,
|
||||
stand: armsDown,
|
||||
t_pose: {},
|
||||
@@ -293,7 +345,7 @@ export function directorPoseBoneDeltas(pose: DirectorPose): Partial<Record<Direc
|
||||
return poses[pose];
|
||||
}
|
||||
|
||||
export function directorColorLabel(value: string) {
|
||||
export function previsColorLabel(value: string) {
|
||||
const hsl = { h: 0, s: 0, l: 0 };
|
||||
new Color(value).getHSL(hsl);
|
||||
if (hsl.l >= 0.86 && hsl.s <= 0.2) return "白色";
|
||||
@@ -310,10 +362,10 @@ export function directorColorLabel(value: string) {
|
||||
return "粉色";
|
||||
}
|
||||
|
||||
function poseQuaternion(x: number, y: number, z: number): DirectorQuat {
|
||||
return new Quaternion().setFromEuler(new Euler(x, y, z)).toArray() as DirectorQuat;
|
||||
function poseQuaternion(x: number, y: number, z: number): PrevisQuat {
|
||||
return new Quaternion().setFromEuler(new Euler(x, y, z)).toArray() as PrevisQuat;
|
||||
}
|
||||
|
||||
function lerpVec3(from: DirectorVec3, to: DirectorVec3, progress: number): DirectorVec3 {
|
||||
return from.map((value, index) => value + (to[index] - value) * progress) as DirectorVec3;
|
||||
function lerpVec3(from: PrevisVec3, to: PrevisVec3, progress: number): PrevisVec3 {
|
||||
return from.map((value, index) => value + (to[index] - value) * progress) as PrevisVec3;
|
||||
}
|
||||
+5
-5
@@ -1,17 +1,17 @@
|
||||
import type { DirectorScene } from "../../../types/director";
|
||||
import type { PrevisScene } from "../../../types/previs";
|
||||
|
||||
/**
|
||||
* 打开期间本地 draft 是唯一权威:只有 scene id 变化(或首次挂载)才重建会话。
|
||||
* 同 id 的父级镜像回流绝不能重新 clone / 清历史 / 重置播放头。
|
||||
*/
|
||||
export function shouldReinitializeDirectorSession(input: { initializedSceneId: string | null; nextSceneId: string | null }) {
|
||||
export function shouldReinitializePrevisSession(input: { initializedSceneId: string | null; nextSceneId: string | null }) {
|
||||
const { initializedSceneId, nextSceneId } = input;
|
||||
if (!nextSceneId) return false;
|
||||
return initializedSceneId !== nextSceneId;
|
||||
}
|
||||
|
||||
/** 以 id upsert,调用方必须传入「当前最新」数组,避免旧闭包覆盖并发保存。 */
|
||||
export function upsertDirectorSceneById(scenes: DirectorScene[], scene: DirectorScene): DirectorScene[] {
|
||||
export function upsertPrevisSceneById(scenes: PrevisScene[], scene: PrevisScene): PrevisScene[] {
|
||||
return scenes.some((item) => item.id === scene.id) ? scenes.map((item) => (item.id === scene.id ? scene : item)) : [...scenes, scene];
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function upsertDirectorSceneById(scenes: DirectorScene[], scene: Director
|
||||
* 输出上传可能跨越数秒。只把生成出的预览引用合并到最新场景,绝不拿输出开始时的
|
||||
* scene 快照覆盖期间发生的标题、对象、关键帧或镜头编辑。
|
||||
*/
|
||||
export function mergeDirectorOutputPreview(scene: DirectorScene, input: { sceneId: string; shotId: string; previewNodeId: string }) {
|
||||
export function mergePrevisOutputPreview(scene: PrevisScene, input: { sceneId: string; shotId: string; previewNodeId: string }) {
|
||||
if (scene.id !== input.sceneId || !scene.shots.some((shot) => shot.id === input.shotId)) return null;
|
||||
return {
|
||||
...scene,
|
||||
@@ -28,7 +28,7 @@ export function mergeDirectorOutputPreview(scene: DirectorScene, input: { sceneI
|
||||
}
|
||||
|
||||
/** 长截图/录制结束前,场景引用与活动镜头必须仍是操作开始时的快照。 */
|
||||
export function isDirectorOutputSnapshotCurrent(scene: DirectorScene | null, expected: { scene: DirectorScene; shotId: string }) {
|
||||
export function isPrevisOutputSnapshotCurrent(scene: PrevisScene | null, expected: { scene: PrevisScene; 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;
|
||||
+17
-17
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 导演台键盘快捷键解析。
|
||||
* 预演台键盘快捷键解析。
|
||||
*
|
||||
* 纯函数:只把「按键事件形状」映射为语义动作,不碰 DOM、不改场景。
|
||||
* 组件层负责注册监听与执行动作,因此这里可以完整单测。
|
||||
@@ -10,17 +10,17 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* 语义动作。只保留导演台里已经存在真实执行路径的动作:
|
||||
* 变换模式(DirectorViewportDock 的 W/E/R)、删除选中对象或灯光、
|
||||
* 语义动作。只保留预演台里已经存在真实执行路径的动作:
|
||||
* 变换模式(PrevisViewportDock 的 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" };
|
||||
export type PrevisShortcutAction = { 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 = {
|
||||
export type PrevisKeyEvent = {
|
||||
key: string;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
@@ -45,7 +45,7 @@ const TRANSFORM_KEYS: Record<string, "translate" | "rotate" | "scale"> = {
|
||||
* - 单字母快捷键不区分大小写,Shift+W 仍是 translate;
|
||||
* - Redo 同时接受 Ctrl/Cmd+Shift+Z 与 Ctrl/Cmd+Y。
|
||||
*/
|
||||
export function resolveDirectorShortcut(event: DirectorKeyEvent): DirectorShortcutAction | null {
|
||||
export function resolvePrevisShortcut(event: PrevisKeyEvent): PrevisShortcutAction | null {
|
||||
if (event.isInteractiveTarget) return null;
|
||||
|
||||
const key = String(event.key || "");
|
||||
@@ -73,8 +73,8 @@ export function resolveDirectorShortcut(event: DirectorKeyEvent): DirectorShortc
|
||||
|
||||
/**
|
||||
* 交互控件的唯一真源。
|
||||
* DIRECTOR_INTERACTIVE_SELECTOR(真实 DOM 的 closest 用)与
|
||||
* isDirectorInteractiveNode(单节点判定)都从这里派生,避免两处清单漂移。
|
||||
* PREVIS_INTERACTIVE_SELECTOR(真实 DOM 的 closest 用)与
|
||||
* isPrevisInteractiveNode(单节点判定)都从这里派生,避免两处清单漂移。
|
||||
*/
|
||||
const INTERACTIVE_TAGS = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A", "OPTION", "LABEL", "SUMMARY"] as const;
|
||||
|
||||
@@ -105,13 +105,13 @@ const INTERACTIVE_ROLES = [
|
||||
] 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(",");
|
||||
export const PREVIS_INTERACTIVE_SELECTOR = [...INTERACTIVE_TAGS.map((tag) => tag.toLowerCase()), ...INTERACTIVE_ROLES.map((role) => `[role="${role}"]`), '[contenteditable="true"]', '[contenteditable=""]'].join(",");
|
||||
|
||||
/**
|
||||
* 判断单个节点自身是否是交互控件。不看祖先。
|
||||
* 祖先由 blocksDirectorShortcut 通过 closest 处理。
|
||||
* 祖先由 blocksPrevisShortcut 通过 closest 处理。
|
||||
*/
|
||||
export function isDirectorInteractiveNode(target: unknown): boolean {
|
||||
export function isPrevisInteractiveNode(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;
|
||||
@@ -137,27 +137,27 @@ export function isDirectorInteractiveNode(target: unknown): boolean {
|
||||
*
|
||||
* 没有 closest 的目标(window、document、测试替身)退化为单节点判定。
|
||||
*/
|
||||
export function blocksDirectorShortcut(target: unknown): boolean {
|
||||
export function blocksPrevisShortcut(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);
|
||||
if (typeof node.closest === "function") return Boolean(node.closest(PREVIS_INTERACTIVE_SELECTOR));
|
||||
return isPrevisInteractiveNode(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指针激活后释放焦点,使全局快捷键立刻可用。
|
||||
*
|
||||
* 为什么需要:blocksDirectorShortcut 会吃掉聚焦控件上的所有全局快捷键(这是刻意的,
|
||||
* 为什么需要:blocksPrevisShortcut 会吃掉聚焦控件上的所有全局快捷键(这是刻意的,
|
||||
* Space/Delete/Backspace 属于控件自己)。但鼠标点完一个工具按钮或场景行之后,
|
||||
* 焦点仍留在按钮上,于是「点选对象 -> 按 Delete」这类主流程会静默失效。
|
||||
*
|
||||
* detail === 0 表示键盘激活(Enter/Space 合成的 click),此时必须保留焦点,
|
||||
* 否则 Tab 序列会断、键盘用户会丢失位置。判据与 plugins 页一致。
|
||||
*
|
||||
* 导演台的按钮分散在 workbench、dock、sequencer 三处,各自没有公共基组件,
|
||||
* 预演台的按钮分散在 workbench、dock、sequencer 三处,各自没有公共基组件,
|
||||
* 因此这条规则集中在这里,避免每个 onClick 各写一遍而慢慢走偏。
|
||||
*/
|
||||
export function releaseDirectorFocusAfterPointer(event: { detail: number; currentTarget: { blur?: () => void } }): void {
|
||||
export function releasePrevisFocusAfterPointer(event: { detail: number; currentTarget: { blur?: () => void } }): void {
|
||||
if (event.detail === 0) return;
|
||||
event.currentTarget.blur?.();
|
||||
}
|
||||
+26
-26
@@ -1,25 +1,25 @@
|
||||
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";
|
||||
import { createPrevisActor, createPrevisCamera, createPrevisLight, createPrevisObject, previsFocalLengthToFov, PREVIS_ACTOR_COLORS } from "@/lib/canvas/previs/previs-scene";
|
||||
import type { PrevisCamera, PrevisLight, PrevisObject, PrevisScene, PrevisShot } from "@/types/previs";
|
||||
|
||||
/**
|
||||
* 新建场景模板。
|
||||
*
|
||||
* 为什么存在:createDirectorScene 无条件塞一个默认演员,产品/空场景根本不需要它,
|
||||
* 为什么存在:createPrevisScene 无条件塞一个默认演员,产品/空场景根本不需要它,
|
||||
* 而双人、走位这类常见开局每次都要手工摆一遍。模板把「开局布局」变成显式选择。
|
||||
*
|
||||
* 硬约束:
|
||||
* - 纯函数 + 确定性布局。同一模板每次生成的结构完全一致,只有 id 与时间戳不同。
|
||||
* - 每次调用都产生独立 id(工厂内部 nanoid),两个实例不会共享对象身份。
|
||||
* - 不改 DirectorScene schema/version:模板只是预填内容,不引入新字段。
|
||||
* - 对象之间 XZ 占位不重叠,判据与 resolveDirectorPlacement 完全一致。
|
||||
* - 不改 PrevisScene schema/version:模板只是预填内容,不引入新字段。
|
||||
* - 对象之间 XZ 占位不重叠,判据与 resolvePrevisPlacement 完全一致。
|
||||
*/
|
||||
|
||||
export type DirectorTemplateId = "empty" | "monologue" | "dialogue" | "blocking" | "product";
|
||||
export type PrevisTemplateId = "empty" | "monologue" | "dialogue" | "blocking" | "product";
|
||||
|
||||
export type DirectorTemplate = {
|
||||
id: DirectorTemplateId;
|
||||
export type PrevisTemplate = {
|
||||
id: PrevisTemplateId;
|
||||
name: string;
|
||||
description: string;
|
||||
/** 列表里给用户的预期提示,不参与生成。 */
|
||||
@@ -27,7 +27,7 @@ export type DirectorTemplate = {
|
||||
};
|
||||
|
||||
/** 展示顺序即用户看到的顺序:空场景在最前,其余按上手复杂度递增。 */
|
||||
export const DIRECTOR_TEMPLATES: DirectorTemplate[] = [
|
||||
export const PREVIS_TEMPLATES: PrevisTemplate[] = [
|
||||
{ id: "empty", name: "空场景", description: "只有摄影机和三点布光,没有任何演员或道具。", summary: "摄影机 + 布光" },
|
||||
{ id: "monologue", name: "单人对白", description: "一名演员居中,中近景机位,适合独白与特写。", summary: "1 演员 · 中近景" },
|
||||
{ id: "dialogue", name: "双人对话", description: "两名演员面对面分立左右,过肩机位起手。", summary: "2 演员 · 对话机位" },
|
||||
@@ -36,42 +36,42 @@ export const DIRECTOR_TEMPLATES: DirectorTemplate[] = [
|
||||
];
|
||||
|
||||
type TemplateBlueprint = {
|
||||
objects: DirectorObject[];
|
||||
camera: DirectorCamera;
|
||||
shot: Omit<DirectorShot, "id" | "cameraId">;
|
||||
objects: PrevisObject[];
|
||||
camera: PrevisCamera;
|
||||
shot: Omit<PrevisShot, "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)];
|
||||
function threePointRig(): PrevisLight[] {
|
||||
return [createPrevisLight("directional", "主光", [4, 6, 4], 2.4), createPrevisLight("directional", "轮廓光", [-4, 3, -2], 1.1), createPrevisLight("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);
|
||||
function templateCamera(input: { name: string; position: PrevisCamera["transform"]["position"]; target: PrevisCamera["target"]; focalLength: number; focusDistance: number; aperture: number }): PrevisCamera {
|
||||
const base = createPrevisCamera(input.name);
|
||||
return {
|
||||
...base,
|
||||
transform: { ...base.transform, position: input.position },
|
||||
target: input.target,
|
||||
focalLength: input.focalLength,
|
||||
fov: directorFocalLengthToFov(input.focalLength),
|
||||
fov: previsFocalLengthToFov(input.focalLength),
|
||||
aperture: input.aperture,
|
||||
focusDistance: input.focusDistance,
|
||||
};
|
||||
}
|
||||
|
||||
/** 演员朝向:绕 Y 轴。左侧演员朝右为 +PI/2,右侧演员朝左为 -PI/2。 */
|
||||
function facing(actor: DirectorObject, yaw: number): DirectorObject {
|
||||
function facing(actor: PrevisObject, yaw: number): PrevisObject {
|
||||
return { ...actor, transform: { ...actor.transform, rotation: [0, yaw, 0] } };
|
||||
}
|
||||
|
||||
function buildBlueprint(id: DirectorTemplateId): TemplateBlueprint {
|
||||
function buildBlueprint(id: PrevisTemplateId): TemplateBlueprint {
|
||||
if (id === "monologue") {
|
||||
return {
|
||||
objects: [createDirectorActor("演员 1", [0, 0, 0], DIRECTOR_ACTOR_COLORS[0])],
|
||||
objects: [createPrevisActor("演员 1", [0, 0, 0], PREVIS_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: "" },
|
||||
};
|
||||
@@ -80,7 +80,7 @@ function buildBlueprint(id: DirectorTemplateId): TemplateBlueprint {
|
||||
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)],
|
||||
objects: [facing(createPrevisActor("演员 1", [-0.8, 0, 0.3], PREVIS_ACTOR_COLORS[0]), Math.PI / 2), facing(createPrevisActor("演员 2", [0.8, 0, -0.3], PREVIS_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: "" },
|
||||
};
|
||||
@@ -89,7 +89,7 @@ function buildBlueprint(id: DirectorTemplateId): TemplateBlueprint {
|
||||
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])],
|
||||
objects: [createPrevisActor("演员 1", [-1.8, 0, 0.6], PREVIS_ACTOR_COLORS[0]), createPrevisActor("演员 2", [0, 0, -0.4], PREVIS_ACTOR_COLORS[2]), createPrevisActor("演员 3", [1.8, 0, 0.8], PREVIS_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: "" },
|
||||
};
|
||||
@@ -97,9 +97,9 @@ function buildBlueprint(id: DirectorTemplateId): TemplateBlueprint {
|
||||
|
||||
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");
|
||||
const product = createPrevisObject("box", "产品主体", [0, 0.5, 0], "#b8c0ca");
|
||||
const backdrop = createPrevisObject("plane", "背景板", [0, 1.4, -1.35], "#e8ebef");
|
||||
const prop = createPrevisObject("cylinder", "侧面道具", [1.15, 0.4, 0.5], "#8795a5");
|
||||
return {
|
||||
objects: [
|
||||
{ ...product, transform: { ...product.transform, scale: [0.9, 0.9, 0.9] } },
|
||||
@@ -122,7 +122,7 @@ function buildBlueprint(id: DirectorTemplateId): TemplateBlueprint {
|
||||
* 按模板生成全新场景。
|
||||
* 未知 id 回落到空场景 —— 宁可少给内容,也不要偷偷塞回默认演员。
|
||||
*/
|
||||
export function createDirectorSceneFromTemplate(templateId: DirectorTemplateId, title = "未命名场景"): DirectorScene {
|
||||
export function createPrevisSceneFromTemplate(templateId: PrevisTemplateId, title = "未命名场景"): PrevisScene {
|
||||
const blueprint = buildBlueprint(templateId);
|
||||
const now = new Date().toISOString();
|
||||
const shotId = nanoid();
|
||||
+88
-88
@@ -1,25 +1,25 @@
|
||||
import { Euler, Vector3 } from "three";
|
||||
|
||||
import { interpolateDirectorTransform } from "@/lib/canvas/director/director-scene";
|
||||
import type { DirectorCamera, DirectorScene, DirectorShot, DirectorTransform, DirectorVec3 } from "@/types/director";
|
||||
import { interpolatePrevisTransform } from "@/lib/canvas/previs/previs-scene";
|
||||
import type { PrevisCamera, PrevisScene, PrevisShot, PrevisTransform, PrevisVec3 } from "@/types/previs";
|
||||
|
||||
/**
|
||||
* 视口取景模式(3D / CAM / 五个正交轴向:俯视、正视、背视、左视、右视)。
|
||||
*
|
||||
* 与一级模式(director-modes.ts 的摆场/姿态/动画/摄影机)是两个正交的量:
|
||||
* 与一级模式(previs-modes.ts 的摆场/姿态/动画/摄影机)是两个正交的量:
|
||||
* 一级模式决定「能改什么」,取景模式只决定「从哪只眼睛看」。
|
||||
*
|
||||
* viewMode 是纯视口状态,绝不写入 DirectorScene,也绝不产生 undo/history 记录 ——
|
||||
* viewMode 是纯视口状态,绝不写入 PrevisScene,也绝不产生 undo/history 记录 ——
|
||||
* 换一只眼睛看场景不是对内容的修改。本模块只做纯函数,不持有任何 three 对象。
|
||||
*
|
||||
* 五个正交轴向和 CAM 一样锁环绕,但取景来自场景内容的包围盒而不是某台摄影机:
|
||||
* resolveDirectorOrthographicFraming 解出带 margin 的投影跨度,
|
||||
* resolveDirectorOrthographicFrustum 再按视口宽高比收成安全的正交半范围。
|
||||
* resolvePrevisOrthographicFraming 解出带 margin 的投影跨度,
|
||||
* resolvePrevisOrthographicFrustum 再按视口宽高比收成安全的正交半范围。
|
||||
*/
|
||||
|
||||
export type DirectorViewMode = "free" | "camera" | "top" | "front" | "back" | "left" | "right";
|
||||
export type PrevisViewMode = "free" | "camera" | "top" | "front" | "back" | "left" | "right";
|
||||
|
||||
export type DirectorViewModeCapabilities = {
|
||||
export type PrevisViewModeCapabilities = {
|
||||
/** 是否允许 OrbitControls 自由环绕。只有 free 允许;CAM 与五个正交轴向都要锁定构图,拖拽会破坏取景。 */
|
||||
orbit: boolean;
|
||||
/** 是否由 shot 的摄影机接管取景(视口相机 == 当前镜头)。只有 CAM。 */
|
||||
@@ -28,7 +28,7 @@ export type DirectorViewModeCapabilities = {
|
||||
projection: "perspective" | "orthographic";
|
||||
};
|
||||
|
||||
const CAPABILITIES: Record<DirectorViewMode, DirectorViewModeCapabilities> = {
|
||||
const CAPABILITIES: Record<PrevisViewMode, PrevisViewModeCapabilities> = {
|
||||
// 3D:编辑用的自由视角,可环绕、可缩放,摄影机只被关键帧驱动时才回写。
|
||||
free: { orbit: true, framed: false, projection: "perspective" },
|
||||
// CAM:所见即成片取景,视口 == 当前 shot 的摄影机,禁止环绕。
|
||||
@@ -41,7 +41,7 @@ const CAPABILITIES: Record<DirectorViewMode, DirectorViewModeCapabilities> = {
|
||||
right: { orbit: false, framed: false, projection: "orthographic" },
|
||||
};
|
||||
|
||||
export const DIRECTOR_VIEW_MODES: Array<{ mode: DirectorViewMode; label: string; hint: string }> = [
|
||||
export const PREVIS_VIEW_MODES: Array<{ mode: PrevisViewMode; label: string; hint: string }> = [
|
||||
{ mode: "free", label: "3D", hint: "自由视角:拖拽环绕、滚轮缩放" },
|
||||
{ mode: "camera", label: "CAM", hint: "机位视图:按当前镜头的摄影机取景,环绕已锁定" },
|
||||
{ mode: "top", label: "TOP", hint: "俯视:从正上方向下看,正交投影,环绕已锁定" },
|
||||
@@ -51,11 +51,11 @@ export const DIRECTOR_VIEW_MODES: Array<{ mode: DirectorViewMode; label: string;
|
||||
{ mode: "right", label: "RIGHT", hint: "右视:沿 -X 方向看向场景,正交投影,环绕已锁定" },
|
||||
];
|
||||
|
||||
export const DIRECTOR_DEFAULT_VIEW_MODE: DirectorViewMode = "free";
|
||||
export const PREVIS_DEFAULT_VIEW_MODE: PrevisViewMode = "free";
|
||||
|
||||
/** 未知模式回落默认能力,绝不返回 undefined —— 调用方直接读 .orbit / .framed。 */
|
||||
export function directorViewModeCapabilities(mode: DirectorViewMode): DirectorViewModeCapabilities {
|
||||
return CAPABILITIES[mode] ?? CAPABILITIES[DIRECTOR_DEFAULT_VIEW_MODE];
|
||||
export function previsViewModeCapabilities(mode: PrevisViewMode): PrevisViewModeCapabilities {
|
||||
return CAPABILITIES[mode] ?? CAPABILITIES[PREVIS_DEFAULT_VIEW_MODE];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,32 +64,32 @@ export function directorViewModeCapabilities(mode: DirectorViewMode): DirectorVi
|
||||
* activeShotId 指向不存在的 shot 时回落到第一个 shot;shot 的 cameraId 指向不存在的
|
||||
* 摄影机时回落到第一台。空场景返回 null 而不是抛异常 —— 视口在任何场景下都必须能渲染。
|
||||
*/
|
||||
export function resolveDirectorActiveShot(scene: DirectorScene): DirectorShot | null {
|
||||
export function resolvePrevisActiveShot(scene: PrevisScene): PrevisShot | 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);
|
||||
export function resolvePrevisActiveCamera(scene: PrevisScene): PrevisCamera | null {
|
||||
const shot = resolvePrevisActiveShot(scene);
|
||||
return scene.cameras.find((item) => item.id === shot?.cameraId) || scene.cameras[0] || null;
|
||||
}
|
||||
|
||||
/** 取景数据:CAM 模式下视口相机需要被写入的完整状态。 */
|
||||
export type DirectorViewFraming = {
|
||||
export type PrevisViewFraming = {
|
||||
cameraId: string;
|
||||
position: DirectorVec3;
|
||||
target: DirectorVec3;
|
||||
position: PrevisVec3;
|
||||
target: PrevisVec3;
|
||||
/** 世界系 up。保留 roll,且保证与视线不共线。 */
|
||||
up: DirectorVec3;
|
||||
up: PrevisVec3;
|
||||
fov: number;
|
||||
near: number;
|
||||
far: number;
|
||||
};
|
||||
|
||||
// 叉积长度低于此值即视为共线:lookAt 会解出非法基向量(NaN 矩阵),视口直接黑屏。
|
||||
const DIRECTOR_VIEW_COLLINEAR_EPSILON = 1e-6;
|
||||
const PREVIS_VIEW_COLLINEAR_EPSILON = 1e-6;
|
||||
|
||||
// 退化时的备选 up:世界 up 优先,其次两个水平轴 —— 正俯视/正仰视镜头落在这里。
|
||||
const DIRECTOR_VIEW_UP_FALLBACKS: DirectorVec3[] = [
|
||||
const PREVIS_VIEW_UP_FALLBACKS: PrevisVec3[] = [
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[1, 0, 0],
|
||||
@@ -104,12 +104,12 @@ const DIRECTOR_VIEW_UP_FALLBACKS: DirectorVec3[] = [
|
||||
* 再处理退化:正俯视/正仰视时旋转后的 up 与视线共线,叉积为 0,lookAt 解出 NaN 矩阵。
|
||||
* 此时按 世界up -> -Z -> +X 取第一个不共线的备选,绝不返回让相机失效的值。
|
||||
*/
|
||||
export function resolveDirectorViewUp(rotation: DirectorVec3, viewDirection: DirectorVec3): DirectorVec3 {
|
||||
export function resolvePrevisViewUp(rotation: PrevisVec3, viewDirection: PrevisVec3): PrevisVec3 {
|
||||
const view = new Vector3(...viewDirection);
|
||||
if (view.lengthSq() <= DIRECTOR_VIEW_COLLINEAR_EPSILON) return [0, 1, 0];
|
||||
if (view.lengthSq() <= PREVIS_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];
|
||||
const candidates = rolled.lengthSq() > PREVIS_VIEW_COLLINEAR_EPSILON ? [rolled.toArray() as PrevisVec3, ...PREVIS_VIEW_UP_FALLBACKS] : PREVIS_VIEW_UP_FALLBACKS;
|
||||
return candidates.find((candidate) => new Vector3(...candidate).cross(view).length() > PREVIS_VIEW_COLLINEAR_EPSILON) ?? [0, 1, 0];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,7 +118,7 @@ export function resolveDirectorViewUp(rotation: DirectorVec3, viewDirection: Dir
|
||||
* 只检查有限还不够: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 {
|
||||
export function previsUsablePerspectiveProjection(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;
|
||||
@@ -126,7 +126,7 @@ export function directorUsablePerspectiveProjection(input: { fov: number; near:
|
||||
return far > near;
|
||||
}
|
||||
|
||||
export type DirectorEffectiveViewport = {
|
||||
export type PrevisEffectiveViewport = {
|
||||
/** 视口应激活的专用相机。CAM 取景失败时必须是 free,不能停在从未写入的 camCamera。 */
|
||||
camera: "free" | "camera" | "orthographic";
|
||||
/** 是否允许环绕 free 相机。与 camera 同步:只有 free 为 true。 */
|
||||
@@ -137,11 +137,11 @@ export type DirectorEffectiveViewport = {
|
||||
* 视口活动相机与交互的唯一决策。
|
||||
*
|
||||
* CAM 取景为 null(空场景、非法光学参数)时回落到已有的 free 相机和自由环绕,
|
||||
* 绝不改 DirectorScene。取景重新合法后确定性回到 CAM(orbit 锁死)。
|
||||
* 绝不改 PrevisScene。取景重新合法后确定性回到 CAM(orbit 锁死)。
|
||||
* 正交轴向不走这条回落:它们始终有安全默认包围盒。
|
||||
*/
|
||||
export function resolveDirectorEffectiveViewport(input: { mode: DirectorViewMode; framing: DirectorViewFraming | null }): DirectorEffectiveViewport {
|
||||
if (directorViewModeCapabilities(input.mode).projection === "orthographic") return { camera: "orthographic", orbit: false };
|
||||
export function resolvePrevisEffectiveViewport(input: { mode: PrevisViewMode; framing: PrevisViewFraming | null }): PrevisEffectiveViewport {
|
||||
if (previsViewModeCapabilities(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 };
|
||||
}
|
||||
@@ -154,21 +154,21 @@ export function resolveDirectorEffectiveViewport(input: { mode: DirectorViewMode
|
||||
*
|
||||
* 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);
|
||||
export function resolvePrevisViewFraming(input: { scene: PrevisScene; mode: PrevisViewMode; playhead: number; cameraId?: string | null }): PrevisViewFraming | null {
|
||||
if (!previsViewModeCapabilities(input.mode).framed) return null;
|
||||
const camera = (input.cameraId ? input.scene.cameras.find((item) => item.id === input.cameraId) : null) || resolvePrevisActiveCamera(input.scene);
|
||||
if (!camera) return null;
|
||||
const time = Number.isFinite(input.playhead) ? input.playhead : 0;
|
||||
const transform: DirectorTransform = interpolateDirectorTransform(camera.transform, camera.keyframes, time);
|
||||
const transform: PrevisTransform = interpolatePrevisTransform(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;
|
||||
if (!previsUsablePerspectiveProjection({ 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;
|
||||
const degenerate = raw.lengthSq() <= PREVIS_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 };
|
||||
const target: PrevisVec3 = degenerate ? [position[0] + view.x, position[1] + view.y, position[2] + view.z] : camera.target;
|
||||
return { cameraId: camera.id, position, target, up: resolvePrevisViewUp(transform.rotation, view.toArray() as PrevisVec3), fov: camera.fov, near: camera.near, far: camera.far };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,7 +177,7 @@ export function resolveDirectorViewFraming(input: { scene: DirectorScene; mode:
|
||||
* 相机回写的 effect 只依赖这个字符串:草稿对象每次 render 都是新身份,但取景数值没变
|
||||
* 就不该重写相机。数值截到 4 位小数,避免浮点噪声引起无意义的重同步。
|
||||
*/
|
||||
export function directorViewFramingKey(framing: DirectorViewFraming | null): string {
|
||||
export function previsViewFramingKey(framing: PrevisViewFraming | 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}`;
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export function directorViewFramingKey(framing: DirectorViewFraming | null): str
|
||||
/**
|
||||
* 五个正交轴向的视线方向与 up。这五个轴上视线与 up 恒定正交(都是不同的主轴向量),
|
||||
* 不会出现 CAM 取景那种「roll 导致 up 与视线共线」的退化情形,因此不需要
|
||||
* resolveDirectorViewUp 那套回落链。
|
||||
* resolvePrevisViewUp 那套回落链。
|
||||
*
|
||||
* 方向约定(世界系,Y-up):
|
||||
* - top:看向 -Y(从上往下看),up 取 -Z。
|
||||
@@ -194,7 +194,7 @@ export function directorViewFramingKey(framing: DirectorViewFraming | null): str
|
||||
* - left:看向 +X(从场景左侧看向右侧),up 取 +Y。
|
||||
* - right:看向 -X,up 取 +Y —— left 的正对面。
|
||||
*/
|
||||
type DirectorOrthographicAxis = "top" | "front" | "back" | "left" | "right";
|
||||
type PrevisOrthographicAxis = "top" | "front" | "back" | "left" | "right";
|
||||
|
||||
/**
|
||||
* 正交画面上的世界轴。horizontal/vertical 是投影后的屏幕轴,不是视线轴:
|
||||
@@ -202,7 +202,7 @@ type DirectorOrthographicAxis = "top" | "front" | "back" | "left" | "right";
|
||||
* - 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 }> = {
|
||||
const PREVIS_ORTHOGRAPHIC_AXES: Record<PrevisOrthographicAxis, { view: PrevisVec3; up: PrevisVec3; 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 },
|
||||
@@ -211,11 +211,11 @@ const DIRECTOR_ORTHOGRAPHIC_AXES: Record<DirectorOrthographicAxis, { view: Direc
|
||||
};
|
||||
|
||||
/** 穷举类型守卫:合法的正交轴向集合在类型层面就是穷举的,调用方无需再查能力表判断。 */
|
||||
function isDirectorOrthographicAxis(mode: DirectorViewMode): mode is DirectorOrthographicAxis {
|
||||
function isPrevisOrthographicAxis(mode: PrevisViewMode): mode is PrevisOrthographicAxis {
|
||||
return mode === "top" || mode === "front" || mode === "back" || mode === "left" || mode === "right";
|
||||
}
|
||||
|
||||
function directorFinitePoint(point: DirectorVec3): boolean {
|
||||
function previsFinitePoint(point: PrevisVec3): boolean {
|
||||
return point.every(Number.isFinite);
|
||||
}
|
||||
|
||||
@@ -225,69 +225,69 @@ function directorFinitePoint(point: DirectorVec3): boolean {
|
||||
* 只收对象中心会把大比例背景板、长道具等裁掉。资源尚未加载时无法读取真实 geometry,
|
||||
* transform.scale 是当前场景合同里稳定可用的保守代理;旋转后的八个角点还能覆盖斜放对象。
|
||||
*/
|
||||
function collectDirectorFramingPoints(scene: DirectorScene): DirectorVec3[] {
|
||||
const points: DirectorVec3[] = [];
|
||||
function collectPrevisFramingPoints(scene: PrevisScene): PrevisVec3[] {
|
||||
const points: PrevisVec3[] = [];
|
||||
for (const object of scene.objects) {
|
||||
const { position, rotation, scale } = object.transform;
|
||||
if (!object.visible || !directorFinitePoint(position)) continue;
|
||||
if (!object.visible || !previsFinitePoint(position)) continue;
|
||||
points.push(position);
|
||||
if (![...rotation, ...scale].every(Number.isFinite)) continue;
|
||||
const half = scale.map((value) => Math.abs(value) / 2) as DirectorVec3;
|
||||
const half = scale.map((value) => Math.abs(value) / 2) as PrevisVec3;
|
||||
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);
|
||||
points.push(corner.toArray() as PrevisVec3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
for (const camera of scene.cameras) if (previsFinitePoint(camera.transform.position)) points.push(camera.transform.position);
|
||||
for (const light of scene.lights) if (previsFinitePoint(light.transform.position)) points.push(light.transform.position);
|
||||
return points;
|
||||
}
|
||||
|
||||
type DirectorFramingBounds = { center: DirectorVec3; extent: DirectorVec3 };
|
||||
type PrevisFramingBounds = { center: PrevisVec3; extent: PrevisVec3 };
|
||||
|
||||
// 空场景 / 全部点位非法时的安全默认包围盒,贴近场景模板的默认布景尺度(见 director-templates.ts 三点布光半径)。
|
||||
const DIRECTOR_ORTHOGRAPHIC_DEFAULT_BOUNDS: DirectorFramingBounds = { center: [0, 0, 0], extent: [6, 6, 6] };
|
||||
// 空场景 / 全部点位非法时的安全默认包围盒,贴近场景模板的默认布景尺度(见 previs-templates.ts 三点布光半径)。
|
||||
const PREVIS_ORTHOGRAPHIC_DEFAULT_BOUNDS: PrevisFramingBounds = { center: [0, 0, 0], extent: [6, 6, 6] };
|
||||
// 单轴范围下限:单点或共面内容不能把该轴压成 0,否则正交相机等于无限缩放。
|
||||
const DIRECTOR_ORTHOGRAPHIC_MIN_EXTENT = 2;
|
||||
const PREVIS_ORTHOGRAPHIC_MIN_EXTENT = 2;
|
||||
// 构图留白:内容贴着画面边缘看不清楚,按包围盒放大这个比例再取景。
|
||||
const DIRECTOR_ORTHOGRAPHIC_MARGIN = 1.2;
|
||||
const PREVIS_ORTHOGRAPHIC_MARGIN = 1.2;
|
||||
// 相机与目标的距离相对包围盒半径的倍数:太近会让正交相机的机位穿进内容内部。
|
||||
const DIRECTOR_ORTHOGRAPHIC_DISTANCE_FACTOR = 2;
|
||||
const DIRECTOR_ORTHOGRAPHIC_NEAR = 0.1;
|
||||
const PREVIS_ORTHOGRAPHIC_DISTANCE_FACTOR = 2;
|
||||
const PREVIS_ORTHOGRAPHIC_NEAR = 0.1;
|
||||
// far 相对距离额外预留的深度倍数,保证视线方向上的内容不被裁切。
|
||||
const DIRECTOR_ORTHOGRAPHIC_FAR_MARGIN = 4;
|
||||
const PREVIS_ORTHOGRAPHIC_FAR_MARGIN = 4;
|
||||
// 视锥换算的跨度下限:0 / 负 / 非有限值都不能当除数或半范围。
|
||||
const DIRECTOR_ORTHOGRAPHIC_MIN_SPAN = 1e-3;
|
||||
const PREVIS_ORTHOGRAPHIC_MIN_SPAN = 1e-3;
|
||||
// 非法宽高比(0 / 负 / NaN / Infinity)回落正方形,避免半范围变成 NaN 或 Infinity。
|
||||
const DIRECTOR_ORTHOGRAPHIC_DEFAULT_ASPECT = 1;
|
||||
const PREVIS_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];
|
||||
function resolvePrevisFramingBounds(points: PrevisVec3[]): PrevisFramingBounds {
|
||||
if (points.length === 0) return PREVIS_ORTHOGRAPHIC_DEFAULT_BOUNDS;
|
||||
const min: PrevisVec3 = [Infinity, Infinity, Infinity];
|
||||
const max: PrevisVec3 = [-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;
|
||||
const center = [0, 1, 2].map((axis) => (min[axis] + max[axis]) / 2) as PrevisVec3;
|
||||
const extent = [0, 1, 2].map((axis) => Math.max(max[axis] - min[axis], PREVIS_ORTHOGRAPHIC_MIN_EXTENT)) as PrevisVec3;
|
||||
return { center, extent };
|
||||
}
|
||||
|
||||
/** 正交轴向取景数据:视口相机需要被写入的完整状态。 */
|
||||
export type DirectorOrthographicFraming = {
|
||||
position: DirectorVec3;
|
||||
target: DirectorVec3;
|
||||
export type PrevisOrthographicFraming = {
|
||||
position: PrevisVec3;
|
||||
target: PrevisVec3;
|
||||
/** 世界系 up,恒定为主轴向量,绝不与视线共线。 */
|
||||
up: DirectorVec3;
|
||||
up: PrevisVec3;
|
||||
/** 投影后的水平跨度(已含构图留白)。不得单独按高度×宽高比展开,否则宽内容会被裁切。 */
|
||||
horizontalSpan: number;
|
||||
/** 投影后的竖直跨度(已含构图留白)。 */
|
||||
@@ -297,7 +297,7 @@ export type DirectorOrthographicFraming = {
|
||||
};
|
||||
|
||||
/** 正交相机的左右上下半范围。始终对称,且 width/height 等于安全后的宽高比。 */
|
||||
export type DirectorOrthographicFrustum = {
|
||||
export type PrevisOrthographicFrustum = {
|
||||
halfWidth: number;
|
||||
halfHeight: number;
|
||||
left: number;
|
||||
@@ -306,7 +306,7 @@ export type DirectorOrthographicFrustum = {
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
function directorSafePositive(value: number, fallback: number): number {
|
||||
function previsSafePositive(value: number, fallback: number): number {
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
@@ -317,10 +317,10 @@ function directorSafePositive(value: number, fallback: number): number {
|
||||
* 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);
|
||||
export function resolvePrevisOrthographicFrustum(input: { horizontalSpan: number; verticalSpan: number; aspect: number }): PrevisOrthographicFrustum {
|
||||
const horizontalSpan = previsSafePositive(input.horizontalSpan, PREVIS_ORTHOGRAPHIC_MIN_SPAN);
|
||||
const verticalSpan = previsSafePositive(input.verticalSpan, PREVIS_ORTHOGRAPHIC_MIN_SPAN);
|
||||
const aspect = previsSafePositive(input.aspect, PREVIS_ORTHOGRAPHIC_DEFAULT_ASPECT);
|
||||
const neededHalfWidth = horizontalSpan / 2;
|
||||
const neededHalfHeight = verticalSpan / 2;
|
||||
const halfWidth = Math.max(neededHalfWidth, neededHalfHeight * aspect);
|
||||
@@ -342,21 +342,21 @@ export function resolveDirectorOrthographicFrustum(input: { horizontalSpan: numb
|
||||
* 位置非法(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));
|
||||
export function resolvePrevisOrthographicFraming(input: { scene: PrevisScene; mode: PrevisViewMode }): PrevisOrthographicFraming | null {
|
||||
if (!isPrevisOrthographicAxis(input.mode)) return null;
|
||||
const axis = PREVIS_ORTHOGRAPHIC_AXES[input.mode];
|
||||
const bounds = resolvePrevisFramingBounds(collectPrevisFramingPoints(input.scene));
|
||||
const radius = Math.max(...bounds.extent) / 2;
|
||||
const distance = Math.max(radius * DIRECTOR_ORTHOGRAPHIC_DISTANCE_FACTOR, DIRECTOR_ORTHOGRAPHIC_MIN_EXTENT);
|
||||
const distance = Math.max(radius * PREVIS_ORTHOGRAPHIC_DISTANCE_FACTOR, PREVIS_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];
|
||||
const position: PrevisVec3 = [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,
|
||||
horizontalSpan: bounds.extent[axis.horizontal] * PREVIS_ORTHOGRAPHIC_MARGIN,
|
||||
verticalSpan: bounds.extent[axis.vertical] * PREVIS_ORTHOGRAPHIC_MARGIN,
|
||||
near: PREVIS_ORTHOGRAPHIC_NEAR,
|
||||
far: distance + radius * PREVIS_ORTHOGRAPHIC_FAR_MARGIN,
|
||||
};
|
||||
}
|
||||
@@ -23,8 +23,8 @@ export const addNodeMenuCommands: AddNodeMenuCommand[] = [
|
||||
nodeCommand(CanvasNodeType.Video, { defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() }),
|
||||
nodeCommand(CanvasNodeType.BatchTable, { defaultOrder: 66, run: (ctx) => ctx.handlers.onAddExtensionNode(CanvasNodeType.BatchTable) }),
|
||||
nodeCommand(CanvasNodeType.MediaConversion, { badge: "本地", defaultOrder: 65, run: (ctx) => ctx.handlers.onAddExtensionNode(CanvasNodeType.MediaConversion) }),
|
||||
// 导演台落在节点分区,但它开的是导演工作台、不是某种画布节点,故不走注册表。
|
||||
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", section: "node", defaultOrder: 70, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
|
||||
// 预演台落在节点分区,但它开的是导演工作台、不是某种画布节点,故不走注册表。
|
||||
{ id: "previs", label: "预演台", icon: <Layers3 />, badge: "3D", section: "node", defaultOrder: 70, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenPrevis() },
|
||||
nodeCommand(CanvasNodeType.Audio, { defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() }),
|
||||
// 云端和本地工作流共用独立配置节点,不进入基础模型节点的渠道选择。
|
||||
{ id: "workflow", label: "工作流", icon: <Workflow />, section: "workflow", defaultOrder: 10, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddWorkflow() },
|
||||
|
||||
@@ -47,7 +47,7 @@ export type ToolbarHandlers = {
|
||||
*/
|
||||
onAddExtensionNode: (type: CanvasNodeTypeId) => void;
|
||||
onChooseStyle: () => void;
|
||||
onOpenDirector: () => void;
|
||||
onOpenPrevis: () => void;
|
||||
// 主工具栏——资源
|
||||
onUpload: () => void;
|
||||
onOpenWorkspace?: () => void;
|
||||
@@ -151,7 +151,7 @@ export type AddNodeMenuContext = {
|
||||
| "onAddWorkflow"
|
||||
| "onAddExtensionNode"
|
||||
| "onChooseStyle"
|
||||
| "onOpenDirector"
|
||||
| "onOpenPrevis"
|
||||
| "onUpload"
|
||||
| "onOpenMyAssets"
|
||||
| "onOpenProjectCharacters"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export function isIsolatedDirectorRepro(dev: boolean, pathname: string): boolean {
|
||||
return dev && pathname === "/dev/director-repro";
|
||||
export function isIsolatedPrevisRepro(dev: boolean, pathname: string): boolean {
|
||||
return dev && pathname === "/dev/previs-repro";
|
||||
}
|
||||
Reference in new issue
Block a user