fix(canvas): 3D 导演台 - 完成 Issue #305 P0 稳定性治理 (#324)

* fix(canvas): 3D 导演台 - 修复状态回弹与保存恢复生命周期

* style(canvas): 3D 导演台 - 对齐 CI 格式门禁
This commit is contained in:
echoD886 authored and GitHub committed 2026-08-27 10:32:45 +08:00
1 parent 2a28ababf1
commit c2766544d7
42 files changed
+8124 -188

No files matched your search

@@ -0,0 +1,136 @@
import { Euler, Quaternion } from "three";
import type { DirectorBoneKeyframe, DirectorKeyframe, DirectorQuat, DirectorTransform, DirectorVec3 } from "../../../types/director";
import { interpolateDirectorBoneRotation, interpolateDirectorTransform, upsertDirectorKeyframe } from "./director-scene";
// 缩放为 0 时无法用比例表达增量,改用绝对偏移;阈值同时兼顾数值噪声。
const SCALE_EPSILON = 1e-6;
export type DirectorTransformDelta = {
position: DirectorVec3;
rotation: DirectorQuat;
scaleRatio: DirectorVec3;
scaleOffset: DirectorVec3;
};
export function directorTransformDelta(from: DirectorTransform, to: DirectorTransform): DirectorTransformDelta {
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];
from.scale.forEach((value, index) => {
if (Math.abs(value) > SCALE_EPSILON) {
scaleRatio[index] = to.scale[index] / value;
return;
}
scaleOffset[index] = to.scale[index] - value;
});
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,
scaleRatio,
scaleOffset,
};
}
export function applyDirectorTransformDelta(transform: DirectorTransform, delta: DirectorTransformDelta): DirectorTransform {
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,
};
}
export function snapDirectorTime(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[];
};
/**
* 静态与动画语义的唯一入口。
* autoKey 打开:只写当前吸附播放头上的关键帧,base 与其他关键帧保持不变。
* autoKey 关闭:把「渲染值 -> 编辑值」的增量整体搬到 base 和所有已有关键帧,
* 使编辑在存在关键帧时依然可见,且不改变未被编辑场景的既有渲染结果。
*/
export function resolveDirectorObjectTransformEdit(input: { base: DirectorTransform; keyframes: DirectorKeyframe[]; rendered: DirectorTransform; edited: DirectorTransform; autoKey: boolean; time: number }): DirectorObjectTransformEdit {
const { base, keyframes, rendered, edited, autoKey, time } = input;
if (autoKey) return { transform: base, keyframes: upsertDirectorKeyframe(keyframes, time, edited) };
if (!keyframes.length) return { transform: edited, keyframes };
const delta = directorTransformDelta(rendered, edited);
return {
transform: applyDirectorTransformDelta(base, delta),
keyframes: keyframes.map((keyframe) => ({ ...keyframe, transform: applyDirectorTransformDelta(keyframe.transform, delta) })),
};
}
export type DirectorBoneLayerInput = {
/** 有动作片段时由 mixer 求值的当前旋转;无动作时为空。 */
motion?: DirectorQuat | null;
/** 静默姿势下的骨骼静置旋转。 */
rest?: DirectorQuat | null;
/** 姿势预设相对静置的增量。 */
poseDelta?: DirectorQuat | null;
/** 静态骨骼覆盖值。 */
override?: DirectorQuat | null;
/** 骨骼关键帧轨道。 */
keyframes?: DirectorBoneKeyframe[] | null;
time: number;
};
/**
* 骨骼求值优先级(低到高):静置/姿势或动作片段 -> 静态覆盖 -> 骨骼关键帧。
* 手指骨骼与主干骨骼共用同一规则。
*/
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;
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);
}
export type DirectorGestureState = { active: boolean; committed: boolean; transforming: boolean };
export type DirectorGestureEvent = "start" | "commit" | "cancel";
export const directorGestureIdle: DirectorGestureState = { active: false, committed: false, transforming: false };
/**
* 一次手势只允许一个终态:commit 恰好提交一次,任何终态都必须清掉 transforming,
* 否则 OrbitControls 会一直停留在禁用状态。
*/
export function reduceDirectorGesture(state: DirectorGestureState, event: DirectorGestureEvent): DirectorGestureState {
if (event === "start") return { active: true, committed: false, transforming: true };
if (!state.active) return { ...directorGestureIdle, committed: false };
if (event === "commit") return { active: false, committed: true, transforming: false };
return { active: false, committed: false, transforming: false };
}
/**
* 记录关键帧:取值时间与写入时间是两个不同的量。
* 取值用 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) };
}
/**
* 暂停且相机没有被关键帧驱动时不写相机,避免与用户 Orbit 操作互相抢夺。
* 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 }) {
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 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;
}
@@ -0,0 +1,48 @@
import { recordDiagnosticEvent } from "@/services/diagnostics/client-diagnostics";
import { formatDirectorDiagnosticCode, projectDirectorDiagnostic, type DirectorDiagnosticCode, type DirectorDiagnosticFields } from "@/lib/canvas/director/director-diagnostics";
/**
* 导演台故障事件的唯一记录入口。
*
* 复用统一的 client-diagnostics 缓冲区,绝不另建平行日志。
* 只传固定 message 与安全 code/字段;stack 一律不填。
*/
/** 去重窗口:同一 code+字段组合在窗口内只记一次,避免高频路径灌满缓冲区。 */
const DEDUPE_WINDOW_MS = 1500;
const lastSeen = new Map<string, number>();
function now(): number {
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
}
/** 测试与页面重置用:清空去重状态。 */
export function resetDirectorDiagnosticDedupe(): void {
lastSeen.clear();
}
/**
* 记录一条导演台诊断事件。
* 返回是否真的写入(未知 code 或命中去重窗口时返回 false),便于测试断言。
*/
export function recordDirectorDiagnostic(code: DirectorDiagnosticCode, fields: DirectorDiagnosticFields = {}): boolean {
const event = projectDirectorDiagnostic(code, fields);
if (!event) return false;
const signature = formatDirectorDiagnosticCode(event);
const timestamp = now();
const previous = lastSeen.get(signature);
if (previous !== undefined && timestamp - previous < DEDUPE_WINDOW_MS) return false;
lastSeen.set(signature, timestamp);
recordDiagnosticEvent({
level: event.level,
category: "runtime",
// code 携带 scene/object/枚举后缀,便于在统一诊断流里检索而无需自由文本。
// 不写 canvasId:DirectorScene.id 不是 canvas id,冒充会污染既有后端契约的语义。
code: signature,
message: event.message,
});
return true;
}
@@ -0,0 +1,181 @@
/**
* 导演台诊断领域:稳定白名单 code + 安全结构化字段。
*
* 调用边界的硬约束(安全边界,不是风格偏好):
* - 绝不接收 Error / stack / componentStack / URL / Cookie / Authorization / token / 密钥;
* - 绝不接收 scene 名称、素材正文、prompt 等业务文本;
* - message 只能来自本模块的固定常量表,调用方不能自带文案;
* - 字段只允许「白名单枚举 | 布尔 | 有限数值 | 通过 safe-id 校验的 id」;
* - 未知 code 与未知字段一律丢弃,不透传。
*/
/** 故障与恢复代码白名单。新增代码必须同时补 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",
] as const;
export type DirectorDiagnosticCode = (typeof DIRECTOR_DIAGNOSTIC_CODES)[number];
const CODE_SET: ReadonlySet<string> = new Set(DIRECTOR_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 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",
};
/** 对象种类枚举:只表达形态,绝不表达名称或素材来源。 */
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);
/** 保存决策枚举:与 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 type DirectorDiagnosticFields = {
/** 对象 id:safe-id 校验;不通过则丢弃。 */
objectId?: string;
/** 场景 id:safe-id 校验。绝不接收 scene 名称。 */
sceneId?: string;
objectKind?: DirectorDiagnosticObjectKind;
saveOutcome?: DirectorDiagnosticSaveOutcome;
/** 加载/保存重试次数。 */
attempt?: number;
/** 修订号。 */
revision?: number;
/** 本地草稿是否可用。 */
draftStored?: boolean;
/** 是否由用户显式触发(区分自动重试与手动重试)。 */
userInitiated?: boolean;
};
export type DirectorDiagnosticEvent = {
code: DirectorDiagnosticCode;
level: "info" | "warning" | "error";
message: string;
fields: DirectorDiagnosticFields;
};
const SAFE_ID = /^[A-Za-z0-9._:-]{1,96}$/;
export function isDirectorDiagnosticCode(value: unknown): value is DirectorDiagnosticCode {
return typeof value === "string" && CODE_SET.has(value);
}
/** id 白名单校验:任何含 URL、query、路径分隔、空格、凭证形态的输入都不合法。 */
function safeDirectorId(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return SAFE_ID.test(trimmed) ? trimmed : undefined;
}
function boundedInteger(value: unknown, min: number, max: number): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
const rounded = Math.round(value);
if (rounded < min || rounded > max) return undefined;
return rounded;
}
/**
* 纯投影:把任意输入收敛成安全事件。未知 code 返回 null,调用方据此丢弃。
* 未知字段、非法 id、越界数值、非布尔值一律不出现在输出里。
*/
export function projectDirectorDiagnostic(code: unknown, fields: unknown): DirectorDiagnosticEvent | null {
if (!isDirectorDiagnosticCode(code)) return null;
const source: Record<string, unknown> = fields && typeof fields === "object" && !Array.isArray(fields) ? (fields as Record<string, unknown>) : {};
const safe: DirectorDiagnosticFields = {};
const objectId = safeDirectorId(source.objectId);
if (objectId) safe.objectId = objectId;
const sceneId = safeDirectorId(source.sceneId);
if (sceneId) safe.sceneId = sceneId;
if (typeof source.objectKind === "string" && OBJECT_KIND_SET.has(source.objectKind)) {
safe.objectKind = source.objectKind as DirectorDiagnosticObjectKind;
}
if (typeof source.saveOutcome === "string" && SAVE_OUTCOME_SET.has(source.saveOutcome)) {
safe.saveOutcome = source.saveOutcome as DirectorDiagnosticSaveOutcome;
}
const attempt = boundedInteger(source.attempt, 0, 999);
if (attempt !== undefined) safe.attempt = attempt;
const revision = boundedInteger(source.revision, 0, Number.MAX_SAFE_INTEGER);
if (revision !== undefined) safe.revision = revision;
if (typeof source.draftStored === "boolean") safe.draftStored = source.draftStored;
if (typeof source.userInitiated === "boolean") safe.userInitiated = source.userInitiated;
return { code, level: LEVELS[code], message: MESSAGES[code], fields: safe };
}
/**
* 把安全字段压成 code 后缀,便于在统一诊断流里检索而无需自由文本。
*
* scene/object 走 safe-id 投影后的值(非法 id 在投影阶段已被丢弃,不会到这里),
* 因此后缀里只可能出现受限字符集。完整签名同时用于去重:
* 不同 objectId / sceneId 必须是不同事件,不能在去重窗口内互相吞掉。
*/
export function formatDirectorDiagnosticCode(event: DirectorDiagnosticEvent): 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);
if (event.fields.objectKind) parts.push("kind=" + event.fields.objectKind);
if (event.fields.saveOutcome) parts.push("outcome=" + event.fields.saveOutcome);
if (event.fields.attempt !== undefined) parts.push("attempt=" + event.fields.attempt);
if (event.fields.revision !== undefined) parts.push("revision=" + event.fields.revision);
if (event.fields.draftStored !== undefined) parts.push("draft=" + (event.fields.draftStored ? "1" : "0"));
if (event.fields.userInitiated !== undefined) parts.push("user=" + (event.fields.userInitiated ? "1" : "0"));
return parts.join(" ");
}
/** 从 DirectorObject 的形态推导枚举,不读取 name / url / storageKey。 */
export function directorDiagnosticObjectKind(object: { kind?: string; primitive?: string } | null | undefined): DirectorDiagnosticObjectKind {
if (!object) return "unknown";
if (object.kind === "actor") return "actor";
if (object.kind === "model") return "model";
if (object.kind === "billboard") return "billboard";
if (typeof object.primitive === "string" && object.primitive) return "primitive";
return "unknown";
}
@@ -0,0 +1,114 @@
import { directorGestureIdle, reduceDirectorGesture } from "./director-animation-semantics";
export type DirectorTransactionHooks<TSnapshot> = {
/** 读取当前值;返回 null 表示无法开始/结束事务。 */
read: () => TSnapshot | null;
/** 取消路径:把快照写回。 */
restore: (snapshot: TSnapshot) => void;
/** 成功路径:恰好调用一次。 */
commit: (from: TSnapshot, to: TSnapshot) => void;
/** 进入/退出手势;任何终态都会收到 false。 */
setActive: (active: boolean) => void;
/** 取消时结束第三方控件内部拖拽状态。 */
terminateDrag?: () => void;
};
export type DirectorTransaction = {
begin: () => void;
end: (outcome: "commit" | "cancel") => void;
active: () => boolean;
};
/**
* 一次手势一个事务:begin 抓快照,end 恰好产生一个终态。
* 幂等——非活跃时的 end 是空操作,终态后可以重新 begin。
*/
export function createDirectorTransaction<TSnapshot>(hooks: DirectorTransactionHooks<TSnapshot>): DirectorTransaction {
let state = directorGestureIdle;
// 快照可能是任何值(含 0 / ""),所以用独立标记表示「有快照」,不能用真值判断。
let snapshot: TSnapshot | null = null;
let hasSnapshot = false;
return {
begin: () => {
if (state.active) return;
const current = hooks.read();
if (current === null || current === undefined) return;
snapshot = current;
hasSnapshot = true;
state = reduceDirectorGesture(state, "start");
hooks.setActive(true);
},
end: (outcome) => {
if (!state.active) return;
const base = snapshot;
const settled = reduceDirectorGesture(state, outcome);
const hadSnapshot = hasSnapshot;
snapshot = null;
hasSnapshot = false;
state = directorGestureIdle;
// 先进入终态再结束第三方拖拽:stdlib 在 domElement.ownerDocument 注册 pointerup,
// 其 pointerUp({button:0}) 会 dispatch mouseUp 后清 dragging=false / axis=null。
// 该回调会回到本事务,此时已非活跃,因此不会二次提交。
if (outcome === "cancel") hooks.terminateDrag?.();
if (!hadSnapshot || base === null || base === undefined) {
hooks.setActive(false);
return;
}
if (settled.committed) {
const current = hooks.read();
if (current !== null && current !== undefined) hooks.commit(base, current);
} else {
hooks.restore(base);
}
hooks.setActive(false);
},
active: () => state.active,
};
}
export type DirectorListenerTarget = {
addEventListener: (type: string, listener: (event: Event) => void) => void;
removeEventListener: (type: string, listener: (event: Event) => void) => void;
};
export type DirectorTerminalTargets = {
/** 承载 keydown / blur / pointercancel / pointerup 的目标(通常是 window)。 */
window: DirectorListenerTarget;
/** 承载 visibilitychange 的目标(通常是 document)。 */
document: DirectorListenerTarget;
/** 读取可见性;hidden 视为「离开」而非「放弃」。 */
isHidden: () => boolean;
};
/**
* 无条件安装终止监听,绝不以「当前是否有手势」为安装条件——
* 手势只 mutate 事务内部状态,不会重跑安装逻辑。
* 非活跃时 transaction.end 自身是空操作,因此常驻监听是安全的。
*
* 终态语义(#305 验收):
* - pointerup / window blur / document hidden → commit 当前可见值。
* 失焦或切页只是「离开」,用户已经把对象拖到那里了,不能把它弹回原位。
* - Escape / pointercancel → cancel 并恢复快照,这是用户明确表达的放弃。
*/
export function installDirectorTerminalListeners(transaction: DirectorTransaction, targets: DirectorTerminalTargets) {
const cancel = () => transaction.end("cancel");
const commit = () => transaction.end("commit");
const onKeyDown = (event: Event) => {
if ((event as KeyboardEvent).key === "Escape") cancel();
};
const onVisibility = () => {
if (targets.isHidden()) commit();
};
targets.window.addEventListener("keydown", onKeyDown);
targets.window.addEventListener("blur", commit);
targets.window.addEventListener("pointercancel", cancel);
targets.window.addEventListener("pointerup", commit);
targets.document.addEventListener("visibilitychange", onVisibility);
return () => {
targets.window.removeEventListener("keydown", onKeyDown);
targets.window.removeEventListener("blur", commit);
targets.window.removeEventListener("pointercancel", cancel);
targets.window.removeEventListener("pointerup", commit);
targets.document.removeEventListener("visibilitychange", onVisibility);
};
}
@@ -0,0 +1,132 @@
import type { DirectorObject, DirectorVec3 } from "../../../types/director";
export type DirectorFootprint = { width: number; depth: number };
export type DirectorPlacementCandidate = { footprint: DirectorFootprint; position: DirectorVec3 };
/** 摆放最小间隙,保证新对象与既有对象之间肉眼可分。 */
export const DIRECTOR_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 {
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 };
if (object.primitive === "sphere") return { width: 1.2, depth: 1.2 };
if (object.primitive === "cylinder") return { width: 1, depth: 1 };
return { width: 1, depth: 1 };
}
/** XZ 平面占位:只用 scale 的 x/z,取绝对值以容忍负缩放;不参与 Y 判断。 */
export function directorObjectFootprint(object: Pick<DirectorObject, "kind" | "primitive" | "transform">): DirectorFootprint {
const base = baseFootprint(object);
const scaleX = Math.abs(object.transform?.scale?.[0] ?? 1);
const scaleZ = Math.abs(object.transform?.scale?.[2] ?? 1);
return {
width: Math.max(0.05, base.width * (Number.isFinite(scaleX) && scaleX > 0 ? scaleX : 1)),
depth: Math.max(0.05, base.depth * (Number.isFinite(scaleZ) && scaleZ > 0 ? scaleZ : 1)),
};
}
function overlaps(a: DirectorPlacementCandidate, b: DirectorPlacementCandidate, 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;
}
/** 确定性 ring 采样:先原位,再逐环外扩;索引顺序固定,便于测试与复现。 */
function candidateOffsets() {
const offsets: Array<[number, number]> = [[0, 0]];
for (let ring = 1; ring <= MAX_RINGS; ring += 1) {
const radius = ring * SEARCH_STEP;
for (let sample = 0; sample < SAMPLES_PER_RING; sample += 1) {
const angle = (sample / SAMPLES_PER_RING) * Math.PI * 2;
offsets.push([radius * Math.cos(angle), radius * Math.sin(angle)]);
}
}
return offsets;
}
/**
* 为新对象求不与任何既有对象 XZ 占位相交的位置。
* 只改 X/Z,保留调用方给定的 Y(actor/model 的地面 y、primitive 的 0.5、billboard 的 1.1)。
* 隐藏对象同样占位;ring 采样耗尽时回退到「所有占位最右边界之外」的确定性位置,
* 该位置在有限 existing 集合下必然不与任何 AABB 相交。
*/
export function resolveDirectorPlacement(input: { object: Pick<DirectorObject, "kind" | "primitive" | "transform">; existing: Array<Pick<DirectorObject, "kind" | "primitive" | "transform">>; margin?: number }): DirectorVec3 {
const { object, existing } = input;
const margin = input.margin ?? DIRECTOR_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 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 }];
});
for (const [offsetX, offsetZ] of candidateOffsets()) {
const position: DirectorVec3 = [originX + offsetX, safeY, originZ + offsetZ];
if (!occupied.some((item) => overlaps({ footprint, position }, item, margin))) return position;
}
return [clearRightOfAll(occupied, footprint, margin, originX), safeY, originZ];
}
/**
* 取所有占位的最右边界,再让新对象整体位于其右侧。
* 因为 |dx| >= halfX + margin 恒成立,overlaps 的 X 条件必然为假,故一定不相交。
*/
function clearRightOfAll(occupied: DirectorPlacementCandidate[], footprint: DirectorFootprint, 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 的阈值。
return maxRight + footprint.width / 2 + margin * 2;
}
/** 地面锚点:只有 XZ,Y 恒由构造器决定,绝不从这里流入。 */
export type DirectorGroundPoint = { x: number; z: number };
/**
* 新增对象时的空间意图。两个来源都可能缺失(尚未移动过 pointer、上下文不可用)。
* 顺序固定:pointer > orbit target > 对象自身默认 XZ。
*/
export type DirectorPlacementIntent = {
pointer: DirectorGroundPoint | null;
orbitTarget: DirectorGroundPoint | null;
};
export const emptyDirectorPlacementIntent: DirectorPlacementIntent = { pointer: null, orbitTarget: null };
/**
* 归一化任意数值来源为合法地面点:非有限(NaN / ±Infinity)一律判为不可用。
* 射线与地面平行、相机在平面内、上下文半失效时都会产出这类值,必须在入口挡掉。
*/
export function finiteDirectorGroundPoint(x: unknown, z: unknown): DirectorGroundPoint | null {
if (typeof x !== "number" || typeof z !== "number") return null;
if (!Number.isFinite(x) || !Number.isFinite(z)) return null;
return { x, z };
}
/**
* 按固定优先级选出锚点 XZ,并严格保留 fallback 的 Y。
* fallback 来自构造器(primitive 0.5 / actor 0 / model 0 / billboard 1.1),
* 因此「贴地对象不悬空、billboard 不落地」这一语义只依赖构造器,不受指针高度影响。
*
* 两个来源都在选择时重新归一化:intent 可能携带结构完整但数值非法的点
* (NaN / ±Infinity)。若只用 `??` 判空,非法 pointer 会遮蔽合法 orbitTarget,
* 两者都非法时也无法可靠回退 fallback。
*/
export function resolveDirectorPlacementAnchor(input: { intent: DirectorPlacementIntent | null; fallback: DirectorVec3 }): DirectorVec3 {
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 chosen = pointer ?? orbitTarget ?? fallbackPoint ?? { x: 0, z: 0 };
return [chosen.x, fallbackY, chosen.z];
}
@@ -0,0 +1,51 @@
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;
}
@@ -0,0 +1,125 @@
export type DirectorLoadPhase = "loading" | "ready" | "error";
export type DirectorLoadState = {
phase: DirectorLoadPhase;
/** 每次 retry 递增;晚到的旧 generation 回调必须被忽略。 */
generation: number;
};
export type DirectorLoadEvent = { type: "start" } | { type: "loaded"; generation: number } | { type: "failed"; generation: number } | { type: "retry" };
export const directorLoadInitial: DirectorLoadState = { phase: "loading", generation: 0 };
/** 加载状态机:只接受当前 generation 的结果,retry 递增 generation 并回到 loading。 */
export function reduceDirectorLoad(state: DirectorLoadState, event: DirectorLoadEvent): DirectorLoadState {
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 DirectorCanvasTarget = {
addEventListener: (type: string, listener: (event: Event) => void) => void;
removeEventListener: (type: string, listener: (event: Event) => void) => void;
};
/**
* WebGL 上下文监听:lost 必须 preventDefault,否则浏览器不会补发 restored。
* 返回 disposer,随 renderer / 重试 / unmount 精确摘除。
*/
export function installDirectorContextListeners(target: DirectorCanvasTarget, handlers: { onLost: () => void; onRestored: () => void }) {
const onLost = (event: Event) => {
event.preventDefault();
handlers.onLost();
};
const onRestored = () => handlers.onRestored();
target.addEventListener("webglcontextlost", onLost);
target.addEventListener("webglcontextrestored", onRestored);
return () => {
target.removeEventListener("webglcontextlost", onLost);
target.removeEventListener("webglcontextrestored", onRestored);
};
}
/** 加载失败登记表:对象 id -> 该对象自己的 retry。 */
export type DirectorFailedLoads = Record<string, () => void>;
/** 登记信号:除加载态外,还包含「对象已卸载」这一必须注销的情形。 */
export type DirectorLoadSignal = DirectorLoadPhase | "unmounted";
/**
* 按信号登记/注销失败对象。ready/loading/unmounted 都必须移除,
* 否则 retry 回到 loading、或对象被删除后,通知会一直挂着陈旧条目。
*/
export function upsertDirectorFailedLoad(current: DirectorFailedLoads, id: string, signal: DirectorLoadSignal, retry: () => void): DirectorFailedLoads {
if (signal === "error") return current[id] === retry ? current : { ...current, [id]: retry };
return removeDirectorFailedLoad(current, id);
}
/** 对象卸载(删除、隐藏、换 URL/kind、Canvas 重建)必须注销自己,不能残留不存在对象的 Retry。 */
export function removeDirectorFailedLoad(current: DirectorFailedLoads, id: string): DirectorFailedLoads {
if (!(id in current)) return current;
const next = { ...current };
delete next[id];
return next;
}
/**
* WebGL capture 可用性:上下文丢失期间不得继续使用失效 renderer,
* 恢复后必须重新登记 capture context 才算可用。
*/
export type DirectorCaptureState = { registered: boolean; contextLost: boolean };
export const directorCaptureInitial: DirectorCaptureState = { registered: false, contextLost: false };
export function reduceDirectorCapture(state: DirectorCaptureState, event: "register" | "lost" | "restored" | "reset"): DirectorCaptureState {
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;
}
export function directorCaptureUsable(state: DirectorCaptureState) {
return state.registered && !state.contextLost;
}
/**
* 上下文恢复的完整序列,生产 handler 与测试必须共用同一个实现。
* reduceDirectorCapture("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 }) {
input.onAvailability("restored");
input.onRegister(input.readContext());
input.invalidate?.();
}
/**
* 释放 capture context,生产接线与测试共用。
* Canvas 子树因 render error 或重建而卸载时必须走这里:
* 先清掉指向已销毁 renderer 的引用,再把可用性打回初态,
* 否则 ErrorBoundary 显示恢复页后「应用到镜头」仍会拿到死 renderer。
* reset 后仍可由新的 renderer 重新 register,不影响重试路径。
*/
export function releaseDirectorCapture(input: { clearContext: () => void; onAvailability: (event: "reset") => void }) {
input.clearContext();
input.onAvailability("reset");
}
/**
* 加载身份:generation + 解析输入。展示状态必须携带它,
* 这样 prop/retry 变化的第一次 render 就能屏蔽旧资源 —— 不能依赖 effect 之后的 render 才安全。
*/
export function directorLoadIdentity(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 {
return state && state.identity === identity ? state.value : null;
}
@@ -0,0 +1,191 @@
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";
/**
* P0 复现用确定性 fixture。
*
* 硬约束:fixture 自身绝不引用网络资产。
* - 不使用 createDirectorScene(它会放入依赖远端 GLB 的默认演员);
* - 所有对象都是 primitive,不带 url / storageKey / assetId;
* - 所有 id 与时间戳都是字面量,因此每次构造完全一致(可直接断言)。
*
* 注意边界:这只保证「初始 fixture」离线。真实工作台自带的新增控件
* (默认演员、上传模型、画布图片立牌)仍可能联网,因此模型相关的复现项
* 需要显式注入本地资产,不能假装当前 fixture 已经覆盖。
*/
const FIXTURE_CREATED_AT = "2026-01-01T00:00:00.000Z";
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 {
return {
id: input.id,
name: input.name,
kind: "primitive",
primitive: input.primitive,
color: input.color,
visible: true,
castShadow: true,
receiveShadow: true,
transform: { position: input.position, rotation: [0, 0, 0], scale: [1, 1, 1] },
keyframes: [],
};
}
/**
* 构造 fixture 场景。同一 seed 下结构完全确定,不含任何远端资产。
* 返回全新对象,调用方可以自由改写而不影响后续构造。
*/
export function createDirectorReproScene(): DirectorScene {
return {
id: FIXTURE_SCENE_ID,
version: 1,
title: "P0 复现场景",
background: "#d8dde3",
environmentIntensity: 0.7,
gridVisible: true,
objects: [
fixtureObject({ id: "repro-box-1", name: "立方体 A", primitive: "box", position: [0, 0.5, 0], color: "#8795a5" }),
fixtureObject({ id: "repro-sphere-1", name: "球体 B", primitive: "sphere", position: [2.2, 0.5, 0], color: "#6f8fb8" }),
fixtureObject({ id: "repro-cylinder-1", name: "圆柱 C", primitive: "cylinder", position: [-2.2, 0.5, 0], color: "#b8926f" }),
],
cameras: [
{
id: FIXTURE_CAMERA_ID,
name: "主摄影机",
transform: { position: [4.8, 2.7, 6.8], rotation: [0, 0, 0], scale: [1, 1, 1] },
target: [0, 1, 0],
focalLength: 35,
fov: 50,
aperture: 2.8,
focusDistance: 5,
near: 0.05,
far: 500,
keyframes: [],
},
],
lights: [
{
id: "repro-light-1",
name: "主光",
type: "directional",
color: "#ffffff",
intensity: 2.4,
castShadow: true,
transform: { position: [4, 6, 4], rotation: [0, 0, 0], scale: [1, 1, 1] },
},
],
shots: [
{
id: FIXTURE_SHOT_ID,
name: "镜头 1",
cameraId: FIXTURE_CAMERA_ID,
shotSize: "medium",
cameraMove: "static",
duration: 4,
fps: 24,
prompt: "",
},
],
activeShotId: FIXTURE_SHOT_ID,
createdAt: FIXTURE_CREATED_AT,
updatedAt: FIXTURE_CREATED_AT,
};
}
/** fixture 是否完全不依赖网络资产:任何 url/storageKey/assetId 都算违约。 */
export function directorReproSceneIsOffline(scene: DirectorScene): boolean {
return scene.objects.every((object) => !object.url && !object.storageKey && !object.assetId);
}
/** P0 手工复现矩阵。页面据此渲染清单,Stage D 可以逐条执行。 */
export type DirectorReproCase = {
id: string;
group: string;
title: string;
steps: string;
expected: string;
};
export const DIRECTOR_REPRO_MATRIX: readonly DirectorReproCase[] = [
{ 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。" },
{ id: "release-outside", group: "手势终结", title: "视口外释放", steps: "在视口内按下并拖动,移出视口后松开鼠标。", expected: "手势以 commit 终结一次,不留下悬挂预览。" },
{ id: "blur", group: "手势终结", title: "窗口 blur", steps: "拖动过程中切换到其它窗口。", expected: "手势终结,草稿状态一致,不重复镜像。" },
{ id: "escape", group: "手势终结", title: "Escape 取消", steps: "拖动过程中按 Escape。", expected: "恢复到手势前快照,且被取消的值绝不发布。" },
{ id: "playing", group: "时间轴", title: "播放中操作", steps: "点击播放,播放中尝试选择与拖动。", expected: "播放不被打断成错误状态;改动语义与暂停时一致。" },
{ id: "non-zero-time", group: "时间轴", title: "非 0 秒操作", steps: "把播放头拖到非 0 且非帧格整数位置后拖动对象。", expected: "增量以 raw playhead 为起点,不产生漂移。" },
{ id: "autokey-on", group: "时间轴", title: "Auto Key 开", steps: "打开 Auto Key,在非 0 秒拖动对象。", expected: "在吸附后的目的时间写入关键帧。" },
{ 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: "save-failed", group: "保存", title: "保存失败", steps: "让保存 flush 抛错后观察头部状态与重试。", expected: "状态为错误,草稿保留时提示可恢复,重试可再次尝试。" },
{ id: "close-recovery", group: "保存", title: "关闭与草稿恢复", steps: "保存失败后关闭;重开同一场景。", expected: "关闭走 prepareClose 决策;重开时提示恢复或放弃,放弃后不再重复提示。" },
];
/** 注入变体:本地可达 glTF,或确定性不可达地址。 */
export type DirectorReproModelVariant = "local" | "missing";
/** 固定 id:重复注入必须替换同一对象,而不是不断堆叠新对象。 */
export const DIRECTOR_REPRO_MODEL_IDS: Record<DirectorReproModelVariant, string> = {
local: "repro-model-local",
missing: "repro-model-missing",
};
/** 同源本地资产,不依赖外网可达性。
* 手写 glTF 2.0:内嵌 base64 buffer、无纹理、无压缩扩展,
* 因此不需要 KTX2Loader / DRACOLoader 也能被 GLTFLoader 直接解析。 */
export const DIRECTOR_REPRO_LOCAL_MODEL_URL = "/canvas/models/director-repro-triangle.gltf";
/** 同源但确定不存在:稳定触发加载失败路径,不依赖外网可达性。 */
export const DIRECTOR_REPRO_MISSING_MODEL_URL = "/__director-repro-missing.glb";
const MODEL_URLS: Record<DirectorReproModelVariant, string> = {
local: DIRECTOR_REPRO_LOCAL_MODEL_URL,
missing: DIRECTOR_REPRO_MISSING_MODEL_URL,
};
/** local 是 JSON 形态 glTF,missing 仍按二进制声明,各自与 URL 后缀一致。 */
const MODEL_MIME_TYPES: Record<DirectorReproModelVariant, string> = {
local: "model/gltf+json",
missing: "model/gltf-binary",
};
const MODEL_NAMES: Record<DirectorReproModelVariant, string> = {
local: "本地模型 repro triangle",
missing: "缺失模型(预期失败)",
};
/**
* 纯函数:把一个模型对象注入场景副本并返回新场景,绝不改写入参。
*
* 语义要点:
* - 固定 id,重复注入先移除同 id 再重算位置,因此不会堆叠也不会自我碰撞;
* - 只改 XZ,Y 严格保留 createDirectorModel 给出的 0(模型贴地);
* - 位置经 resolveDirectorPlacement 避让现有对象;
* - 初始 fixture 本身不含模型,注入是显式动作,不污染离线基线。
*/
export function injectDirectorReproModel(scene: DirectorScene, variant: DirectorReproModelVariant): DirectorScene {
const id = DIRECTOR_REPRO_MODEL_IDS[variant];
const base = createDirectorModel({
name: MODEL_NAMES[variant],
assetId: id,
storageKey: undefined,
url: MODEL_URLS[variant],
mimeType: MODEL_MIME_TYPES[variant],
});
const model: DirectorObject = { ...base, id };
// 先剔除同 id 旧对象,避免重复点击时新位置去避让自己。
const others = scene.objects.filter((object) => object.id !== id);
const position = resolveDirectorPlacement({ object: model, existing: others });
return touchDirectorScene({
...scene,
objects: [...others, { ...model, transform: { ...model.transform, position } }],
});
}
@@ -0,0 +1,178 @@
/**
* 复现场景的环境快照投影。
*
* 只在本地开发复现页面展示,绝不自动发网:本模块不引用任何 API 客户端。
* 所有字符串都限长并剥掉 URL 的 query/hash 与凭证形态片段;
* WebGL 不可用时必须稳定降级为 { available: false },不能抛错。
*/
export type DirectorReproRuntime = {
appVersion: string;
buildCommit: string;
browser: string;
os: string;
timezone: string;
devicePixelRatio: number;
};
export type DirectorReproWebgl =
| { available: false; reason: "unsupported" | "context-failed" }
| {
available: true;
version: string;
vendor: string;
renderer: string;
maxTextureSize: number;
maxRenderbufferSize: number;
maxViewportWidth: number;
maxViewportHeight: number;
};
export type DirectorReproSnapshot = {
runtime: DirectorReproRuntime;
webgl: DirectorReproWebgl;
};
const MAX_TEXT = 160;
/**
* 安全文本:遮蔽凭证、整段替换 URL、压缩空白并限长。
* WebGL 的 UNMASKED_RENDERER 在部分驱动上会带较长标识甚至驱动地址,必须统一收敛。
*
* 顺序有意义:先吃掉 `bearer <token>`(token 与关键字之间只有空白,没有 :=),
* 否则后面的 key[:=]value 规则只会遮住 "bearer" 本身而把 token 留在原地。
* URL 一律替换为固定 [URL]:域名与路径本身就可能是凭证载体或内网拓扑信息,
* 只剥 query/hash 不够。
*/
export function safeReproText(value: unknown, maxLength = MAX_TEXT): string {
if (typeof value !== "string") return "";
let text = value.trim();
if (!text) return "";
text = text.replace(/\bbearer\s+[^\s,;]+/gi, "Bearer [REDACTED]");
text = text.replace(/(authorization|cookie|set-cookie|api[_-]?key|secret[_-]?key|password|token)\s*[:=]\s*[^\s,;]+/gi, "$1=[REDACTED]");
text = text.replace(/https?:\/\/\S+/gi, "[URL]");
// 裸 query/hash 片段(不带协议)同样不得保留。
text = text.replace(/[?#]\S*/g, "");
text = text.replace(/\s+/g, " ").trim();
return text.slice(0, maxLength);
}
function boundedNumber(value: unknown, min: number, max: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, value));
}
function boundedInteger(value: unknown, min: number, max: number): number {
return Math.round(boundedNumber(value, min, max));
}
export function readDirectorReproRuntime(): DirectorReproRuntime {
const nav = typeof navigator === "undefined" ? null : navigator;
let timezone = "";
try {
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
} catch {
timezone = "";
}
return {
appVersion: safeReproText(import.meta.env.VITE_APP_VERSION || "dev", 48),
buildCommit: safeReproText(import.meta.env.VITE_BUILD_COMMIT || "unknown", 48),
browser: safeReproText(nav?.userAgent),
os: safeReproText(nav?.platform, 48),
timezone: safeReproText(timezone, 64),
devicePixelRatio: boundedNumber(typeof window === "undefined" ? 1 : window.devicePixelRatio, 0.1, 8),
};
}
type GlLike = {
getParameter: (name: number) => unknown;
getExtension: (name: string) => unknown;
VERSION: number;
VENDOR: number;
RENDERER: number;
MAX_TEXTURE_SIZE: number;
MAX_RENDERBUFFER_SIZE: number;
MAX_VIEWPORT_DIMS: number;
};
/**
* 从已有上下文投影安全 WebGL 信息。注入 gl 便于测试降级分支。
* 任何 getParameter/getExtension 抛错都收敛为 context-failed,不外泄异常。
*/
export function projectDirectorWebgl(gl: unknown): DirectorReproWebgl {
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" };
try {
// UNMASKED_* 需要扩展;拿不到就退回未遮蔽的 VENDOR/RENDERER,两者都可能为空。
let vendor = context.getParameter(context.VENDOR);
let renderer = context.getParameter(context.RENDERER);
try {
const debugInfo = typeof context.getExtension === "function" ? context.getExtension("WEBGL_debug_renderer_info") : null;
if (debugInfo && typeof debugInfo === "object") {
const info = debugInfo as { UNMASKED_VENDOR_WEBGL?: number; UNMASKED_RENDERER_WEBGL?: number };
if (typeof info.UNMASKED_VENDOR_WEBGL === "number") vendor = context.getParameter(info.UNMASKED_VENDOR_WEBGL) ?? vendor;
if (typeof info.UNMASKED_RENDERER_WEBGL === "number") renderer = context.getParameter(info.UNMASKED_RENDERER_WEBGL) ?? renderer;
}
} catch {
// 扩展不可用时保留未遮蔽值,不视为失败。
}
const viewportDims = context.getParameter(context.MAX_VIEWPORT_DIMS);
const dims = Array.isArray(viewportDims) || viewportDims instanceof Int32Array ? Array.from(viewportDims as ArrayLike<number>) : [];
return {
available: true,
version: safeReproText(context.getParameter(context.VERSION), 96),
vendor: safeReproText(vendor, 96),
renderer: safeReproText(renderer, 96),
maxTextureSize: boundedInteger(context.getParameter(context.MAX_TEXTURE_SIZE), 0, 1_048_576),
maxRenderbufferSize: boundedInteger(context.getParameter(context.MAX_RENDERBUFFER_SIZE), 0, 1_048_576),
maxViewportWidth: boundedInteger(dims[0], 0, 1_048_576),
maxViewportHeight: boundedInteger(dims[1], 0, 1_048_576),
};
} catch {
return { available: false, reason: "context-failed" };
}
}
/**
* 现场探测:创建一次性上下文读取能力,失败稳定降级。
* 读取完成后 best-effort 归还上下文:浏览器的 WebGL 上下文数量有限,
* 一次性 canvas 不释放会挤占真实视口的额度。
* 释放发生在快照取好之后,且异常一律吞掉,绝不影响已读到的结果。
*/
export function probeDirectorWebgl(): DirectorReproWebgl {
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);
releaseProbeContext(gl);
return snapshot;
} catch {
return { available: false, reason: "context-failed" };
}
}
/** 归还探测用上下文;扩展不可用或调用抛错都视为无操作。导出以便测试释放路径。 */
export function releaseProbeContext(gl: unknown): void {
try {
const context = gl as { getExtension?: (name: string) => unknown };
if (typeof context.getExtension !== "function") return;
const loseContext = context.getExtension("WEBGL_lose_context");
if (!loseContext || typeof loseContext !== "object") return;
const losable = loseContext as { loseContext?: () => void };
if (typeof losable.loseContext === "function") losable.loseContext();
} catch {
// 释放失败不影响已取到的 snapshot。
}
}
export function readDirectorReproSnapshot(): DirectorReproSnapshot {
return { runtime: readDirectorReproRuntime(), webgl: probeDirectorWebgl() };
}
@@ -0,0 +1,143 @@
import type { AnimationMixer, Material, Mesh, Object3D, Texture } from "three";
type DisposableMaterial = Material & Record<string, unknown>;
// 材质上可能承载纹理的常见槽位;只释放本 owner 真正引用到的纹理。
const TEXTURE_SLOTS = [
"map",
"alphaMap",
"aoMap",
"bumpMap",
"displacementMap",
"emissiveMap",
"envMap",
"lightMap",
"metalnessMap",
"normalMap",
"roughnessMap",
"specularMap",
"clearcoatMap",
"clearcoatNormalMap",
"clearcoatRoughnessMap",
"sheenColorMap",
"sheenRoughnessMap",
"transmissionMap",
"thicknessMap",
"iridescenceMap",
"specularColorMap",
"specularIntensityMap",
] as const;
function collectTextures(material: DisposableMaterial, sink: Set<Texture>) {
TEXTURE_SLOTS.forEach((slot) => {
const value = material[slot];
// 必须真的是 Texture:仅凭「有 dispose」会把 RenderTarget 等误判成纹理并造成错误释放。
if (isDirectorTexture(value)) sink.add(value);
});
}
function isDirectorTexture(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";
}
/**
* GLTF 加载结果的所有权判定。
* SkeletonUtils.clone 与 source 共享 geometry/material/texture,
* 因此被采纳(adopt)时绝不能释放 source —— 共享资源随 owned clone 的最终 cleanup 一并释放。
* 只有未被采纳的晚到/失效 generation 才需要释放 source,避免孤儿泄漏。
*/
export function resolveDirectorLoadOwnership(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>) {
const owned = new Set<DisposableMaterial>();
materials.forEach((material) => {
if (material && typeof material.dispose === "function") owned.add(material as DisposableMaterial);
});
const textures = new Set<Texture>();
owned.forEach((material) => collectTextures(material, textures));
textures.forEach((texture) => texture.dispose());
owned.forEach((material) => material.dispose());
}
function collectObject3DResources(root: Object3D | null | undefined, geometries: Set<{ dispose: () => void }>, materials: Set<DisposableMaterial>) {
if (!root) return;
root.traverse((child) => {
const mesh = child as Mesh;
if (mesh.geometry && typeof mesh.geometry.dispose === "function") geometries.add(mesh.geometry);
if (!mesh.material) return;
const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
list.forEach((material) => {
if (!material || typeof material.dispose !== "function") return;
materials.add(material as DisposableMaterial);
});
});
}
function disposeCollected(geometries: Set<{ dispose: () => void }>, materials: Set<DisposableMaterial>) {
const textures = new Set<Texture>();
materials.forEach((material) => collectTextures(material, textures));
textures.forEach((texture) => texture.dispose());
materials.forEach((material) => material.dispose());
geometries.forEach((geometry) => geometry.dispose());
}
/**
* 释放一棵 owned Object3D 的 GPU 资源。
* 共享的 geometry/material/texture 用 Set 去重,保证同一资源只 dispose 一次。
* `<primitive object={...}>` 不会代为释放,必须由 owner 显式调用。
*/
export function disposeDirectorObject3D(root: Object3D | null | undefined) {
if (!root) return;
const geometries = new Set<{ dispose: () => void }>();
const materials = new Set<DisposableMaterial>();
collectObject3DResources(root, geometries, materials);
disposeCollected(geometries, materials);
}
/** SkeletonHelper 自带 geometry/material,必须单独释放。 */
export function disposeDirectorHelper(helper: Object3D | null | undefined) {
disposeDirectorObject3D(helper);
}
/** mixer 停止全部动作并解除 root 缓存,避免 clip 绑定泄漏。 */
export function disposeDirectorMixer(mixer: AnimationMixer | null | undefined, root?: Object3D | null) {
if (!mixer) return;
mixer.stopAllAction();
if (root) mixer.uncacheRoot(root as never);
}
/**
* 一次性释放某个模型 generation 拥有的全部资源。
* 只释放 owned clone 与 helper:clone 持有与 source 共享的 GPU 引用,
* 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);
const geometries = new Set<{ dispose: () => void }>();
const materials = new Set<DisposableMaterial>();
collectObject3DResources(input.model, geometries, materials);
collectObject3DResources(input.helper, geometries, materials);
disposeCollected(geometries, materials);
}
/**
* 采纳过程中途抛错时的精确清理,生产与测试共用。
* 二选一,绝不 double-dispose:
* - clone 已建成:它已持有与 source 共享的 GPU 引用,只释放 clone(+mixer),source 交给 GC。
* - clone 尚未建成:本次没有任何 owned 资源,只释放这份未被采纳的 source。
*/
export function disposeDirectorAdoptionFailure(input: { clone?: Object3D | null; mixer?: AnimationMixer | null; source?: Object3D | null }) {
if (input.clone) {
disposeDirectorModelResources({ model: input.clone, mixer: input.mixer });
return;
}
disposeDirectorMixer(input.mixer, null);
disposeDirectorObject3D(input.source);
}
@@ -0,0 +1,122 @@
import type { DirectorScene } from "@/types/director";
import type { DirectorCloseDecision, DirectorDraftEnvelope, DirectorSaveSnapshot, StorageLike } from "@/lib/canvas/director/director-save";
/**
* UI 只需要保存进度,不需要场景本体。
* 去掉 scene 后 React state 不必再为「还没有 coordinator」编造一个假场景。
*/
export type DirectorSaveProgress = Omit<DirectorSaveSnapshot, "scene">;
export const idleDirectorSaveProgress: DirectorSaveProgress = {
revision: 0,
confirmedRevision: 0,
status: "saved",
draftStored: false,
};
/** 从完整快照投影出进度:订阅回调必须走它,避免只更新 status 而漏掉确认信息。 */
export function directorSaveProgress(snapshot: DirectorSaveSnapshot): DirectorSaveProgress {
return {
revision: snapshot.revision,
confirmedRevision: snapshot.confirmedRevision,
status: snapshot.status,
draftStored: snapshot.draftStored,
};
}
/** 头部状态展示:文案与语气必须由状态唯一决定,组件不得自己拼措辞。 */
export type DirectorSaveIndicator = {
label: string;
tone: "idle" | "pending" | "danger";
/** 未确认或错误时提供重试;已保存态没有可重试的东西。 */
retryable: boolean;
busy: boolean;
};
export function describeDirectorSaveStatus(progress: DirectorSaveProgress): DirectorSaveIndicator {
const unconfirmed = progress.confirmedRevision < progress.revision;
if (progress.status === "error") {
return {
label: progress.draftStored ? "保存失败 · 本地草稿已保留" : "保存失败 · 本地草稿也未写入",
tone: "danger",
retryable: true,
busy: false,
};
}
if (progress.status === "saving") {
return { label: "正在保存…", tone: "pending", retryable: false, busy: true };
}
if (progress.status === "dirty" || unconfirmed) {
return { label: "有未保存修改", tone: "pending", retryable: true, busy: false };
}
return { label: "已保存", tone: "idle", retryable: false, busy: false };
}
/**
* 是否提示恢复:候选必须比权威场景更新才值得打断用户。
* scene.updatedAt 是 canonical 保存时间,candidate.baseUpdatedAt 是草稿写入时的基线;
* 基线落后于权威说明远端已保存过更新的内容,这份草稿是陈旧残留。
*/
export function shouldOfferDirectorDraftRecovery(input: { candidate: DirectorDraftEnvelope | null; authoritativeScene: DirectorScene | null }): boolean {
const { candidate, authoritativeScene } = input;
if (!candidate || !authoritativeScene) return false;
if (candidate.sceneId !== authoritativeScene.id) return false;
if (candidate.baseUpdatedAt !== authoritativeScene.updatedAt) return false;
return candidate.scene.updatedAt > authoritativeScene.updatedAt;
}
/** 关闭决策的用户可见后果,组件按此分支决定退出/二次确认/留下。 */
export type DirectorCloseOutcome = { kind: "close" } | { kind: "confirm-draft-exit"; message: string } | { kind: "blocked"; message: string };
export function resolveDirectorCloseOutcome(decision: DirectorCloseDecision): DirectorCloseOutcome {
if (decision === "close") return { kind: "close" };
if (decision === "offer-draft-exit") {
return {
kind: "confirm-draft-exit",
message: "远端保存失败,但本地草稿已安全保存。下次打开这个镜头可以恢复,确认离开吗?",
};
}
return { kind: "blocked", message: "保存失败且本地草稿未写入,已留在导演台,请重试保存。" };
}
/**
* beforeunload 是否要触发浏览器保护:只有「改动未确认」或「错误态且没有安全草稿」才拦。
* 注意这里只能同步表达意图,异步 flush 无法阻塞卸载。
*/
export function shouldBlockDirectorUnload(progress: DirectorSaveProgress): boolean {
if (progress.confirmedRevision < progress.revision) return true;
return progress.status === "error" && !progress.draftStored;
}
/**
* localStorage 不可用(隐私模式、被禁用、SSR)时的显式实现:
* 读返回 null,写和删都抛,让 coordinator 如实得到 draftStored=false。
* 绝不能降级成进程内 Map —— 那会让弹窗谎称「下次可恢复」。
*/
export const unavailableDirectorDraftStorage: StorageLike = {
getItem: () => null,
setItem: () => {
throw new Error("localStorage unavailable");
},
removeItem: () => {
throw new Error("localStorage unavailable");
},
};
/** 纯 factory:注入 getter 便于测试不可用分支,不引入额外依赖。 */
export function resolveDirectorDraftStorage(readStorage: () => Storage | null | undefined): StorageLike {
let local: Storage | null | undefined;
try {
local = readStorage();
} catch {
return unavailableDirectorDraftStorage;
}
if (!local) return unavailableDirectorDraftStorage;
return {
getItem: (key) => local.getItem(key),
setItem: (key, value) => local.setItem(key, value),
removeItem: (key) => local.removeItem(key),
};
}
@@ -0,0 +1,443 @@
import { scopedStorageKey } from "@/lib/user-scope";
import type { DirectorScene } from "@/types/director";
export type DirectorSaveStatus = "dirty" | "saving" | "saved" | "error";
export type DirectorCloseDecision = "close" | "offer-draft-exit" | "stay";
export type DirectorDraftEnvelope = {
schemaVersion: 1;
sceneId: string;
baseUpdatedAt: string;
scene: DirectorScene;
revision: number;
savedAt: number;
};
export type DirectorFlushRequest = {
scene: DirectorScene;
revision: number;
baseUpdatedAt: string;
};
export type StorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
};
export type FlushFunction = (request: Readonly<DirectorFlushRequest>) => Promise<void>;
export type ScheduleFunction = (callback: () => void, delay: number) => unknown;
export type CancelScheduleFunction = (handle: unknown) => void;
export type DirectorSaveSnapshot = {
scene: DirectorScene;
revision: number;
confirmedRevision: number;
status: DirectorSaveStatus;
draftStored: boolean;
};
export type DirectorSaveCoordinatorOptions = {
initialScene: DirectorScene;
scope: string;
storage: StorageLike;
flush: FlushFunction;
now?: () => number;
debounceMs?: number;
schedule?: ScheduleFunction;
cancelSchedule?: CancelScheduleFunction;
};
export interface DirectorSaveCoordinator {
getSnapshot: () => DirectorSaveSnapshot;
edit: (scene: DirectorScene) => boolean;
flushLatest: () => Promise<boolean>;
retry: () => Promise<boolean>;
restoreCandidate: () => DirectorDraftEnvelope | null;
discardDraft: () => boolean;
explicitRestore: (candidate: unknown) => boolean;
prepareClose: () => Promise<DirectorCloseDecision>;
handlePageHide: () => Promise<boolean>;
onStatusChange: (handler: (status: DirectorSaveStatus) => 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 isDirectorScene = (value: unknown, expectedSceneId: string): value is DirectorScene => {
if (!isRecord(value)) return false;
return (
value.id === expectedSceneId &&
value.version === 1 &&
typeof value.title === "string" &&
typeof value.background === "string" &&
typeof value.environmentIntensity === "number" &&
Number.isFinite(value.environmentIntensity) &&
typeof value.gridVisible === "boolean" &&
Array.isArray(value.objects) &&
Array.isArray(value.cameras) &&
Array.isArray(value.lights) &&
Array.isArray(value.shots) &&
typeof value.activeShotId === "string" &&
typeof value.createdAt === "string" &&
typeof value.updatedAt === "string"
);
};
const validateDraftEnvelope = (candidate: unknown, expectedSceneId: string): DirectorDraftEnvelope | null => {
if (!isRecord(candidate)) return null;
if (
candidate.schemaVersion !== 1 ||
candidate.sceneId !== expectedSceneId ||
typeof candidate.baseUpdatedAt !== "string" ||
typeof candidate.revision !== "number" ||
!Number.isSafeInteger(candidate.revision) ||
candidate.revision < 0 ||
typeof candidate.savedAt !== "number" ||
!Number.isFinite(candidate.savedAt) ||
candidate.savedAt < 0 ||
!isDirectorScene(candidate.scene, expectedSceneId)
) {
return null;
}
return {
schemaVersion: 1,
sceneId: expectedSceneId,
baseUpdatedAt: candidate.baseUpdatedAt,
scene: cloneScene(candidate.scene),
revision: candidate.revision,
savedAt: candidate.savedAt,
};
};
const parseDraftEnvelope = (raw: string, expectedSceneId: string): DirectorDraftEnvelope | null => {
try {
const candidate: unknown = JSON.parse(raw);
return validateDraftEnvelope(candidate, expectedSceneId);
} catch {
return null;
}
};
export function createDirectorSaveCoordinator(options: DirectorSaveCoordinatorOptions): DirectorSaveCoordinator {
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);
let currentScene = cloneScene(initialScene);
let currentRevision = 0;
let confirmedRevision = 0;
let baseUpdatedAt = initialScene.updatedAt;
let status: DirectorSaveStatus = "saved";
let localDraft: DirectorDraftEnvelope | null = null;
let draftStored = false;
let debounceHandle: unknown | null = null;
let inFlightFlush: Promise<boolean> | null = null;
let closePromise: Promise<DirectorCloseDecision> | null = null;
let pageHidePromise: Promise<boolean> | null = null;
let disposed = false;
const subscribers = new Set<(status: DirectorSaveStatus) => void>();
const setStatus = (nextStatus: DirectorSaveStatus) => {
if (disposed || status === nextStatus) return;
status = nextStatus;
for (const subscriber of subscribers) {
try {
subscriber(nextStatus);
} catch {
// A subscriber must not break persistence.
}
}
};
const getSnapshot = (): DirectorSaveSnapshot => ({
scene: cloneScene(currentScene),
revision: currentRevision,
confirmedRevision,
status,
draftStored,
});
const cancelDebounce = () => {
if (debounceHandle === null) return;
cancelSchedule(debounceHandle);
debounceHandle = null;
};
const storeCurrentDraft = () => {
const envelope: DirectorDraftEnvelope = {
schemaVersion: 1,
sceneId: fixedSceneId,
baseUpdatedAt,
scene: cloneScene(currentScene),
revision: currentRevision,
savedAt: now(),
};
localDraft = envelope;
try {
storage.setItem(storageKey, JSON.stringify(envelope));
draftStored = true;
setStatus("dirty");
} catch {
draftStored = false;
setStatus("error");
}
};
const scheduleFlush = () => {
cancelDebounce();
debounceHandle = schedule(() => {
debounceHandle = null;
void flushLatest();
}, debounceMs);
};
const edit = (scene: DirectorScene): boolean => {
if (disposed || scene.id !== fixedSceneId) return false;
currentScene = cloneScene(scene);
currentRevision += 1;
storeCurrentDraft();
scheduleFlush();
return true;
};
const clearStoredDraft = (revision: number) => {
if (localDraft?.revision !== revision) return;
try {
storage.removeItem(storageKey);
localDraft = null;
draftStored = false;
} catch {
draftStored = true;
}
};
const refreshStoredDraftBase = () => {
if (!localDraft || localDraft.revision <= confirmedRevision) return;
localDraft = {
...localDraft,
baseUpdatedAt,
};
try {
storage.setItem(storageKey, JSON.stringify(localDraft));
draftStored = true;
} catch {
draftStored = false;
}
};
const runDrain = async (): Promise<boolean> => {
try {
while (!disposed && confirmedRevision < currentRevision) {
const targetRevision = currentRevision;
const targetScene = cloneScene(currentScene);
const targetBaseUpdatedAt = baseUpdatedAt;
setStatus("saving");
try {
await flush({
scene: targetScene,
revision: targetRevision,
baseUpdatedAt: targetBaseUpdatedAt,
});
} catch {
if (!disposed) setStatus("error");
return false;
}
if (disposed) return false;
confirmedRevision = Math.max(confirmedRevision, targetRevision);
baseUpdatedAt = targetScene.updatedAt;
if (confirmedRevision === currentRevision) {
clearStoredDraft(targetRevision);
setStatus("saved");
return true;
}
refreshStoredDraftBase();
}
if (disposed) return false;
setStatus("saved");
return confirmedRevision === currentRevision;
} catch {
if (!disposed) setStatus("error");
return false;
}
};
function flushLatest(): Promise<boolean> {
cancelDebounce();
if (disposed) return Promise.resolve(false);
if (inFlightFlush) return inFlightFlush;
if (confirmedRevision === currentRevision) {
setStatus("saved");
return Promise.resolve(true);
}
const pending = runDrain();
inFlightFlush = pending;
void pending.then(
() => {
if (inFlightFlush === pending) inFlightFlush = null;
},
() => {
if (inFlightFlush === pending) inFlightFlush = null;
},
);
return pending;
}
const retry = (): Promise<boolean> => flushLatest();
const restoreCandidate = (): DirectorDraftEnvelope | null => {
try {
const raw = storage.getItem(storageKey);
if (raw === null) return null;
return parseDraftEnvelope(raw, fixedSceneId);
} catch {
return null;
}
};
/**
* 显式放弃本地草稿:真正删除该 scoped key,避免同一旧草稿每次重开都提示。
* 删除失败返回 false,调用方必须告知用户草稿仍在,不能假装已放弃。
*/
const discardDraft = (): boolean => {
try {
storage.removeItem(storageKey);
} catch {
return false;
}
localDraft = null;
draftStored = false;
return true;
};
const explicitRestore = (candidate: unknown): boolean => {
if (disposed) return false;
const envelope = validateDraftEnvelope(candidate, fixedSceneId);
if (!envelope || envelope.revision <= currentRevision) return false;
cancelDebounce();
currentScene = cloneScene(envelope.scene);
currentRevision = envelope.revision;
baseUpdatedAt = envelope.baseUpdatedAt;
localDraft = envelope;
try {
storage.setItem(storageKey, JSON.stringify(envelope));
draftStored = true;
setStatus("dirty");
} catch {
draftStored = false;
setStatus("error");
}
return true;
};
const prepareClose = (): Promise<DirectorCloseDecision> => {
if (disposed) return Promise.resolve("stay");
if (confirmedRevision === currentRevision) {
return Promise.resolve("close");
}
if (closePromise) return closePromise;
const pending = flushLatest().then<DirectorCloseDecision>((success) => {
if (success) return "close";
return draftStored ? "offer-draft-exit" : "stay";
});
closePromise = pending;
void pending.then(
() => {
if (closePromise === pending) closePromise = null;
},
() => {
if (closePromise === pending) closePromise = null;
},
);
return pending;
};
const handlePageHide = (): Promise<boolean> => {
if (disposed) return Promise.resolve(false);
if (pageHidePromise) return pageHidePromise;
const pending = flushLatest();
pageHidePromise = pending;
void pending.then(
() => {
if (pageHidePromise === pending) pageHidePromise = null;
},
() => {
if (pageHidePromise === pending) pageHidePromise = null;
},
);
return pending;
};
const onStatusChange = (handler: (status: DirectorSaveStatus) => void): (() => void) => {
if (disposed) return () => undefined;
subscribers.add(handler);
return () => {
subscribers.delete(handler);
};
};
const dispose = () => {
if (disposed) return;
disposed = true;
cancelDebounce();
subscribers.clear();
closePromise = null;
pageHidePromise = null;
};
return {
getSnapshot,
edit,
flushLatest,
retry,
restoreCandidate,
discardDraft,
explicitRestore,
prepareClose,
handlePageHide,
onStatusChange,
dispose,
};
}
@@ -0,0 +1,16 @@
import type { DirectorScene } from "../../../types/director";
/**
* 打开期间本地 draft 是唯一权威:只有 scene id 变化(或首次挂载)才重建会话。
* 同 id 的父级镜像回流绝不能重新 clone / 清历史 / 重置播放头。
*/
export function shouldReinitializeDirectorSession(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[] {
return scenes.some((item) => item.id === scene.id) ? scenes.map((item) => (item.id === scene.id ? scene : item)) : [...scenes, scene];
}