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

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

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

View File

@@ -1,14 +1,21 @@
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { Box, Camera, Clapperboard, Lightbulb, LockKeyhole, Move3d } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { gateDirectorPreviewFailure, resolveDirectorActiveShot, resolveDirectorPreviewSource, type DirectorNodeContentReader } from "@/lib/canvas/director/director-preview";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
import type { DirectorScene } from "@/types/director";
export function CanvasDirectorNodePanel({ node, scene, previewUrl, onOpen, professional = true }: { node: CanvasNodeData; scene: DirectorScene | null; previewUrl?: string; onOpen: () => void; professional?: boolean }) {
export function CanvasDirectorNodePanel({ node, scene, readNodeContent, onOpen, professional = true }: { node: CanvasNodeData; scene: DirectorScene | null; readNodeContent: DirectorNodeContentReader; onOpen: () => void; professional?: boolean }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const shot = scene?.shots?.find((item) => item.id === node.metadata?.directorShotId) || scene?.shots?.[0];
const shot = resolveDirectorActiveShot(scene, node.metadata?.directorShotId);
// 记录「失败的那个 URL」而非布尔量:同一个坏 URL 不再反复渲染,换成另一个 URL 时自动重试。
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const preview = gateDirectorPreviewFailure(
resolveDirectorPreviewSource({ scene, shot, previewNodeId: node.metadata?.directorPreviewNodeId, readNodeContent }),
failedUrl,
);
return (
<div className="flex h-full w-full cursor-move flex-col p-3 pt-7" style={{ color: theme.node.text }}>
@@ -27,14 +34,16 @@ export function CanvasDirectorNodePanel({ node, scene, previewUrl, onOpen, profe
type="button"
data-canvas-no-zoom
className="group relative min-h-0 flex-1 cursor-pointer overflow-hidden rounded-lg border text-left focus-visible:outline-none focus-visible:ring-2 disabled:cursor-default"
style={{ background: scene?.background || theme.canvas.background, borderColor: theme.node.stroke }}
style={{ background: theme.node.fill, borderColor: theme.node.stroke }}
title={professional ? "打开 3D 导演台" : "切换到专业模式后编辑导演台"}
disabled={!professional}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => { event.stopPropagation(); onOpen(); }}
>
{previewUrl ? <img src={previewUrl} alt={`${node.title} 场景缩略图`} className="h-full w-full object-cover" draggable={false} /> : <SceneSchematic scene={scene} />}
{preview.kind === "image"
? <img src={preview.url} alt={`${node.metadata?.workflowTitle || node.title} 已回写的导演台构图`} className="h-full w-full object-contain" draggable={false} onError={() => setFailedUrl(preview.url)} />
: <DirectorPreviewState kind={preview.kind} theme={theme} />}
<span className={`absolute inset-x-0 bottom-0 flex h-10 items-center justify-center gap-1.5 text-xs font-semibold backdrop-blur-sm transition-opacity ${professional ? "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100" : "opacity-100"}`} style={{ background: `${theme.toolbar.panel}dd`, color: theme.node.text }}>{professional ? <><Move3d className="size-3.5" />进入导演台</> : <><LockKeyhole className="size-3.5" />专业模式可编辑</>}</span>
</button>
@@ -47,21 +56,19 @@ export function CanvasDirectorNodePanel({ node, scene, previewUrl, onOpen, profe
);
}
function SceneSchematic({ scene }: { scene: DirectorScene | null }) {
/**
* 诚实空态/准备态:不绘制地面、地平线、机位或任何伪 3D 物体。
* 颜色全部走画布主题 token;层级靠字号/字重区分,不靠降低对比度。
*/
function DirectorPreviewState({ kind, theme }: { kind: "loading" | "empty"; theme: CanvasTheme }) {
const loading = kind === "loading";
return (
<div className="relative h-full w-full overflow-hidden">
<div className="absolute inset-x-0 bottom-0 h-[55%] origin-bottom -skew-y-6 border-t border-white/25 bg-black/15" />
<div className="absolute inset-x-[8%] bottom-[18%] h-px bg-white/25" />
<div className="absolute inset-x-[16%] bottom-[32%] h-px bg-white/15" />
{(scene?.objects || []).slice(0, 6).map((object, index) => {
const left = 48 + object.transform.position[0] * 12;
const bottom = 25 + object.transform.position[2] * 7 + Math.max(0, object.transform.position[1]) * 4;
const height = object.primitive === "character" ? 42 : 22 + Math.min(18, object.transform.scale[1] * 8);
return <span key={object.id} className={`absolute border border-white/45 shadow-sm ${object.primitive === "sphere" ? "rounded-full" : "rounded-sm"}`} style={{ left: `${Math.max(12, Math.min(82, left + index * 2))}%`, bottom: `${Math.max(18, Math.min(58, bottom))}%`, width: object.primitive === "character" ? 13 : 22, height, background: object.color, transform: "translateX(-50%)" }} />;
})}
<Camera className="absolute bottom-[14%] left-[12%] size-5 text-white/75" />
<span className="absolute left-[18%] top-[18%] size-16 rounded-full bg-white/10 blur-xl" />
<span className="absolute inset-x-3 top-3 text-[var(--fs-tiny)] font-medium text-white/70">{scene ? "场景预览" : "正在准备场景"}</span>
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-3 pb-10 text-center">
<span className="grid size-10 shrink-0 place-items-center rounded-[var(--r-lg)]" style={{ background: theme.toolbar.activeBg }}>
<Clapperboard className="size-4" style={{ color: theme.node.muted }} aria-hidden />
</span>
<span className="max-w-full truncate text-[var(--fs-tiny)] font-semibold" style={{ color: theme.node.text }}>{loading ? "正在准备场景" : "尚未生成预览"}</span>
<span className="max-w-full text-[var(--fs-tiny)] leading-4" style={{ color: theme.node.muted }}>{loading ? "场景数据加载完成后显示" : "进入导演台并回写构图后显示"}</span>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { App, Button, ColorPicker, Dropdown, Input, InputNumber, Select, Slider, Switch } from "antd";
import type { MenuProps } from "antd";
import { Box, BoxSelect, Camera, Circle, Cuboid, FileUp, Focus, Image as ImageIcon, LampDesk, Lightbulb, Plus, Redo2, Save, Trash2, Undo2, UserRound, Video, X } from "lucide-react";
import { Box, BoxSelect, Camera, Circle, Cuboid, FileUp, Focus, Image as ImageIcon, LampDesk, Lightbulb, Plus, Redo2, RotateCcw, Save, Trash2, Undo2, UserRound, Video, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react";
import { nanoid } from "nanoid";
import { Euler, Quaternion } from "three";
@@ -11,7 +11,14 @@ import { DirectorViewportDock } from "@/components/canvas/director/director-view
import { DirectorSequencer } from "@/components/canvas/director/director-sequencer";
import { canvasThemes } from "@/lib/canvas-theme";
import { compileDirectorPrompt } from "@/lib/canvas/director/director-prompt-compiler";
import { createDirectorActor, createDirectorBillboard, createDirectorCamera, createDirectorLight, createDirectorModel, createDirectorObject, DIRECTOR_ACTOR_COLORS, directorBoneLabel, directorPoseLabel, touchDirectorScene, upsertDirectorBoneKeyframe, upsertDirectorKeyframe } from "@/lib/canvas/director/director-scene";
import { resolveDirectorKeyframeRecord, resolveDirectorObjectTransformEdit, snapDirectorTime } from "@/lib/canvas/director/director-animation-semantics";
import { createDirectorTransaction, installDirectorTerminalListeners, type DirectorTransaction } from "@/lib/canvas/director/director-gesture-transaction";
import { recordDirectorDiagnostic } from "@/lib/canvas/director/director-diagnostics-recorder";
import { resolveDirectorPlacement, resolveDirectorPlacementAnchor } from "@/lib/canvas/director/director-placement";
import { shouldReinitializeDirectorSession } from "@/lib/canvas/director/director-session";
import { createDirectorActor, createDirectorBillboard, createDirectorCamera, createDirectorLight, createDirectorModel, createDirectorObject, DIRECTOR_ACTOR_COLORS, directorBoneLabel, directorPoseLabel, interpolateDirectorTransform, touchDirectorScene, upsertDirectorBoneKeyframe } from "@/lib/canvas/director/director-scene";
import { describeDirectorSaveStatus, resolveDirectorCloseOutcome, shouldBlockDirectorUnload, shouldOfferDirectorDraftRecovery } from "@/lib/canvas/director/director-save-wiring";
import { useDirectorSaveCoordinator } from "@/components/canvas/director/use-director-save-coordinator";
import { uploadMediaFile } from "@/services/file-storage";
import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store";
import { useDirectorWorkbenchStore } from "@/stores/canvas/use-director-workbench-store";
@@ -19,8 +26,8 @@ import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasNodeData } from "@/types/canvas";
import type { DirectorCamera, DirectorCameraMove, DirectorHumanoidBone, DirectorLight, DirectorObject, DirectorPose, DirectorQuat, DirectorRig, DirectorScene, DirectorSceneOutput, DirectorShot, DirectorShotSize, DirectorTransform, DirectorVec3 } from "@/types/director";
export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onChange, onApply, onDeleteImageNode }: { open: boolean; scene: DirectorScene | null; imageNodes: CanvasNodeData[]; onClose: () => void; onChange: (scene: DirectorScene) => void; onApply: (output: DirectorSceneOutput) => Promise<void>; onDeleteImageNode: (nodeId: string) => void }) {
const { message } = App.useApp();
export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onChange, onApply, onDeleteImageNode, onFlush }: { open: boolean; scene: DirectorScene | null; imageNodes: CanvasNodeData[]; onClose: () => void; onChange: (scene: DirectorScene) => void; onApply: (output: DirectorSceneOutput) => Promise<void>; onDeleteImageNode: (nodeId: string) => void; onFlush?: () => void | Promise<void> }) {
const { message, modal } = App.useApp();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const viewportRef = useRef<DirectorViewportHandle>(null);
const modelInputRef = useRef<HTMLInputElement>(null);
@@ -54,20 +61,132 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
const addAsset = useAssetStore((state) => state.addAsset);
const modelAssets = useMemo(() => assets.filter((asset): asset is ModelAsset => asset.kind === "model"), [assets]);
const draftRef = useRef<DirectorScene | null>(null);
const stagedRef = useRef<DirectorTransaction | null>(null);
const initializedSceneIdRef = useRef<string | null>(null);
const onChangeRef = useRef(onChange);
const onFlushRef = useRef(onFlush);
useEffect(() => { onChangeRef.current = onChange; onFlushRef.current = onFlush; }, [onChange, onFlush]);
const closingRef = useRef(false);
const recoveryPromptedRef = useRef<string | null>(null);
const [retrying, setRetrying] = useState(false);
// 按 scene.id 持有唯一 coordinator:flush 先把 request.scene 写回项目,再等持久化完成。
const saveController = useDirectorSaveCoordinator({
sceneId: scene?.id ?? null,
initialScene: scene,
persistScene: (next) => onChangeRef.current(next),
flushPersistence: () => onFlushRef.current?.(),
});
const saveControllerRef = useRef(saveController);
saveControllerRef.current = saveController;
const saveIndicator = describeDirectorSaveStatus(saveController.progress);
const retrySave = async () => {
setRetrying(true);
try {
if (await saveController.retry()) {
recordDirectorDiagnostic("DIRECTOR_SAVE_RETRY_RECOVERED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, userInitiated: true });
message.success("已保存到项目");
return;
}
// 只有真的存在合法本地候选才敢说草稿已保留。
const draftStored = Boolean(saveController.restoreCandidate());
recordDirectorDiagnostic("DIRECTOR_SAVE_RETRY_FAILED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, draftStored, userInitiated: true });
if (!draftStored) recordDirectorDiagnostic("DIRECTOR_SAVE_DRAFT_UNAVAILABLE", { sceneId: draftRef.current?.id, revision: saveController.progress.revision });
if (draftStored) message.error("远端保存失败,本地草稿已保留,可稍后重试");
else message.error("远端和本地都未保存,请不要关闭导演台并继续重试");
} finally {
setRetrying(false);
}
};
const writeDraft = useCallback((next: DirectorScene | null) => {
draftRef.current = next;
setDraft(next);
}, []);
/**
* 仅镜像当前 draft 到项目 directorScenes,不产生 canonical 提交。
* 用于取消预览、idle pagehide、卸载兜底 —— 这些都不是新的用户改动。
*/
const mirrorDraft = useCallback(() => {
const current = draftRef.current;
if (!current || initializedSceneIdRef.current !== current.id) return;
onChangeRef.current(current);
}, []);
/** 真实 canonical 提交:先交给 coordinator(本地草稿 + 远端保存),再镜像到项目。 */
const commitDraft = useCallback(() => {
const current = draftRef.current;
if (!current || initializedSceneIdRef.current !== current.id) return;
saveControllerRef.current?.commitScene(current);
onChangeRef.current(current);
}, []);
const writeAndPublish = useCallback((next: DirectorScene) => {
writeDraft(next);
saveControllerRef.current?.commitScene(next);
onChangeRef.current(next);
}, [writeDraft]);
// 会话初始化只认 scene id:同 id 的父级镜像回流不得重建会话。
useEffect(() => {
if (!open || !scene) return;
if (!shouldReinitializeDirectorSession({ initializedSceneId: initializedSceneIdRef.current, nextSceneId: scene.id })) return;
const next = structuredClone(scene);
next.shots = next.shots.map((shot) => ({ ...shot, fps: shot.fps || 24 }));
setDraft(next);
stagedRef.current?.end("cancel");
initializedSceneIdRef.current = scene.id;
writeDraft(next);
setHistory([]);
setFuture([]);
resetWorkbench();
}, [open, resetWorkbench, scene]);
}, [open, resetWorkbench, scene, writeDraft]);
// 打开会话时检查合法本地恢复候选:同一场景只提示一次,恢复/放弃都必须有明确结果。
useEffect(() => {
if (!open || !scene) return;
if (recoveryPromptedRef.current === scene.id) return;
recoveryPromptedRef.current = scene.id;
const controller = saveControllerRef.current;
const candidate = controller?.restoreCandidate() ?? null;
if (!controller || !candidate) return;
if (!shouldOfferDirectorDraftRecovery({ candidate, authoritativeScene: scene })) return;
modal.confirm({
title: "发现未保存的本地草稿",
content: `这个镜头存在一份比项目更新的本地草稿(修订 ${candidate.revision})。恢复后会立即写回项目并保存。`,
okText: "恢复草稿",
cancelText: "放弃草稿",
closable: false,
mask: { closable: false },
keyboard: false,
onOk: () => {
if (!controller.restoreDraft(candidate)) {
message.error("草稿恢复失败,已保留当前场景");
return;
}
writeDraft(candidate.scene);
message.success("已恢复本地草稿并写回项目");
},
onCancel: () => {
if (controller.discardDraft()) message.success("已放弃本地草稿");
else message.error("草稿删除失败,下次打开可能仍会提示");
},
});
}, [message, modal, open, scene, writeDraft]);
const activeShot = draft?.shots?.find((item) => item.id === draft.activeShotId) || draft?.shots?.[0] || null;
const activeCamera = draft?.cameras?.find((item) => item.id === activeShot?.cameraId) || draft?.cameras?.[0] || null;
const selectedObject = draft?.objects?.find((item) => item.id === selectedObjectId) || null;
const selectedLight = draft?.lights?.find((item) => item.id === selectedLightId) || null;
// 写入关键帧的目的时间用吸附值;取值/显示/手势起点一律用 raw playhead,
// 否则处在两个帧格之间时 AutoKey OFF 的增量会从错误起点计算而产生漂移。
const snappedPlayhead = snapDirectorTime(playhead, activeShot?.fps || 24);
const selectedObjectRendered = selectedObject ? interpolateDirectorTransform(selectedObject.transform, selectedObject.keyframes, playhead) : null;
useEffect(() => {
if (!playing || !activeShot) return;
@@ -85,30 +204,142 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
}, [activeShot, playing, setPlayhead]);
const commit = useCallback((updater: (current: DirectorScene) => DirectorScene) => {
setDraft((current) => {
if (!current) return current;
const next = touchDirectorScene(updater(current));
setHistory((items) => [...items.slice(-49), structuredClone(current)]);
setFuture([]);
return next;
});
}, []);
// 普通提交前先终结暂存手势,避免新动作消费旧 base。
stagedRef.current?.end("commit");
const current = draftRef.current;
if (!current) return;
setHistory((items) => [...items.slice(-49), structuredClone(current)]);
setFuture([]);
writeAndPublish(touchDirectorScene(updater(current)));
}, [writeAndPublish]);
const replaceWithoutHistory = useCallback((updater: (current: DirectorScene) => DirectorScene) => setDraft((current) => (current ? touchDirectorScene(updater(current)) : current)), []);
/** 暂存型手势(数值滑杆):实时预览写草稿但不产生历史,也不镜像到项目。 */
const stagedTransaction = useMemo(() => createDirectorTransaction<DirectorScene>({
read: () => draftRef.current,
// 取消:恢复快照且绝不发布被取消的值。
restore: (snapshot) => writeDraft(snapshot),
commit: (from) => {
setHistory((items) => [...items.slice(-49), from]);
setFuture([]);
// 手势成功终态是真实 canonical 提交。
commitDraft();
},
setActive: () => undefined,
}), [commitDraft, writeDraft]);
stagedRef.current = stagedTransaction;
const stageGesture = useCallback((updater: (current: DirectorScene) => DirectorScene) => {
const current = draftRef.current;
if (!current) return;
stagedTransaction.begin();
writeDraft(touchDirectorScene(updater(current)));
}, [stagedTransaction, writeDraft]);
/** 无历史但持久的变化(标题、rig/motionClips 等)同样要镜像。 */
const replaceWithoutHistory = useCallback((updater: (current: DirectorScene) => DirectorScene) => {
const current = draftRef.current;
if (current) writeAndPublish(touchDirectorScene(updater(current)));
}, [writeAndPublish]);
// 暂存手势的终止生命周期:常驻安装,非活跃时 end 为空操作。
useEffect(() => installDirectorTerminalListeners(stagedTransaction, {
window,
document,
isHidden: () => document.visibilityState === "hidden",
}), [stagedTransaction]);
// 切换选择/骨骼、关闭或卸载前必须先终止旧手势,不能让新选择消费旧 base。
useEffect(() => () => stagedTransaction.end("cancel"), [open, selectedBone, selectedObjectId, stagedTransaction]);
// 离开页面:active 预览由 end("commit") 完成真实提交,idle 只镜像;落盘统一交给 controller。
useEffect(() => {
const onPageHide = () => {
if (stagedTransaction.active()) stagedTransaction.end("commit");
else mirrorDraft();
// 只调用 handlePageHide:dirty 时它自己会 persist + flush,组件再叠一次就是重复落盘。
void saveControllerRef.current?.handlePageHide();
};
// 异步 flush 不可能阻塞卸载:这里只同步声明「仍有未确认改动」,让浏览器自己弹保护。
const onBeforeUnload = (event: BeforeUnloadEvent) => {
const controller = saveControllerRef.current;
if (!controller || !shouldBlockDirectorUnload(controller.progress)) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("pagehide", onPageHide);
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, [mirrorDraft, stagedTransaction]);
// 卸载兜底:只把最新 draft 镜像回项目,不制造新的 canonical revision。
useEffect(() => () => {
stagedRef.current?.end("cancel");
mirrorDraft();
}, [mirrorDraft]);
const undo = () => {
const previous = history.at(-1);
if (!previous || !draft) return;
setHistory((items) => items.slice(0, -1));
setFuture((items) => [structuredClone(draft), ...items].slice(0, 50));
setDraft(previous);
writeAndPublish(previous);
};
const redo = () => {
const next = future[0];
if (!next || !draft) return;
setFuture((items) => items.slice(1));
setHistory((items) => [...items, structuredClone(draft)].slice(-50));
setDraft(next);
writeAndPublish(next);
};
/**
* 关闭统一入口:取消未结束的预览、镜像当前 draft,再按 prepareClose 决策是否真的退出。
* 这里只镜像不提交:取消预览不是新的 canonical 变化。
*/
const closeWorkbench = () => {
if (closingRef.current) return;
closingRef.current = true;
stagedTransaction.end("cancel");
mirrorDraft();
void (async () => {
let decision;
try {
decision = resolveDirectorCloseOutcome(await saveController.prepareClose());
} catch {
message.error("关闭前的保存检查失败,已留在导演台");
closingRef.current = false;
return;
}
if (decision.kind === "close") {
onClose();
return;
}
if (decision.kind === "blocked") {
recordDirectorDiagnostic("DIRECTOR_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision, draftStored: saveController.progress.draftStored });
message.error(decision.message);
closingRef.current = false;
return;
}
// 确认框存续期间保持上锁,否则重复点击会叠出多个弹窗。
modal.confirm({
title: "远端保存失败",
content: decision.message,
okText: "仍然离开",
cancelText: "留在导演台",
closable: false,
mask: { closable: false },
keyboard: false,
onOk: () => onClose(),
onCancel: () => {
closingRef.current = false;
},
});
})();
};
const updateObject = (id: string, patch: Partial<DirectorObject>) => commit((current) => ({ ...current, objects: current.objects.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
@@ -139,24 +370,32 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
}));
};
const addPrimitive = (primitive: DirectorObject["primitive"], name: string) => {
const object = createDirectorObject(primitive, name);
commit((current) => ({ ...current, objects: [...current.objects, object] }));
/**
* 所有「新增到场景」的唯一入口。
* 在 commit 内读取一次 placement intent:因此模型上传等异步路径拿到的是
* 「点击添加完成那一刻」的意图,而不是发起上传时捕获的过时坐标。
* 锚点只提供 XZ,Y 严格保留构造器给定值,再交给 resolveDirectorPlacement 做碰撞避让。
*/
const addObject = (object: DirectorObject) => {
commit((current) => {
const anchored = resolveDirectorPlacementAnchor({
intent: viewportRef.current?.readPlacementIntent() ?? null,
fallback: object.transform.position,
});
const position = resolveDirectorPlacement({ object: { ...object, transform: { ...object.transform, position: anchored } }, existing: current.objects });
return { ...current, objects: [...current.objects, { ...object, transform: { ...object.transform, position } }] };
});
setSelectedObjectId(object.id);
};
const addPrimitive = (primitive: DirectorObject["primitive"], name: string) => addObject(createDirectorObject(primitive, name));
const addActor = () => {
const actorCount = draft?.objects.filter((item) => item.kind === "actor").length || 0;
const actor = createDirectorActor(`演员 ${actorCount + 1}`, [actorCount * 0.8, 0, 0], DIRECTOR_ACTOR_COLORS[actorCount % DIRECTOR_ACTOR_COLORS.length]);
commit((current) => ({ ...current, objects: [...current.objects, actor] }));
setSelectedObjectId(actor.id);
addObject(createDirectorActor(`演员 ${actorCount + 1}`, [0, 0, 0], DIRECTOR_ACTOR_COLORS[actorCount % DIRECTOR_ACTOR_COLORS.length]));
};
const addModelAsset = (asset: ModelAsset) => {
const object = createDirectorModel({ name: asset.title, assetId: asset.id, storageKey: asset.data.storageKey, url: asset.data.url, mimeType: asset.data.mimeType });
commit((current) => ({ ...current, objects: [...current.objects, object] }));
setSelectedObjectId(object.id);
};
const addModelAsset = (asset: ModelAsset) => addObject(createDirectorModel({ name: asset.title, assetId: asset.id, storageKey: asset.data.storageKey, url: asset.data.url, mimeType: asset.data.mimeType }));
const uploadModel = async (file?: File) => {
if (!file || !/\.(glb|gltf)$/i.test(file.name)) return;
@@ -169,9 +408,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
const addBillboard = (node: CanvasNodeData) => {
if (!node.metadata?.content) return;
const object = createDirectorBillboard(node.title, node.metadata.content, node.metadata.storageKey, node.id);
commit((current) => ({ ...current, objects: [...current.objects, object] }));
setSelectedObjectId(object.id);
addObject(createDirectorBillboard(node.title, node.metadata.content, node.metadata.storageKey, node.id));
};
const addCamera = () => {
@@ -212,41 +449,57 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
const addObjectKeyframe = () => {
if (!selectedObject) return;
updateObject(selectedObject.id, { keyframes: upsertDirectorKeyframe(selectedObject.keyframes, playhead, selectedObject.transform) });
// 取值用 raw playhead(视口真正渲染的时间),写入用 snapped 目的时间。
const record = resolveDirectorKeyframeRecord({ base: selectedObject.transform, keyframes: selectedObject.keyframes, rawTime: playhead, snappedTime: snappedPlayhead });
updateObject(selectedObject.id, { keyframes: record.keyframes });
};
const addCameraKeyframe = () => {
if (!activeCamera) return;
commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, keyframes: upsertDirectorKeyframe(item.keyframes, playhead, item.transform) } : item) }));
commit((current) => ({
...current,
cameras: current.cameras.map((item) => item.id === activeCamera.id
? { ...item, keyframes: resolveDirectorKeyframeRecord({ base: item.transform, keyframes: item.keyframes, rawTime: playhead, snappedTime: snappedPlayhead }).keyframes }
: item),
}));
};
const recordSelectedKeyframe = () => {
if (selectedObject && selectedBone) {
const rotation = selectedObject.boneOverrides?.[selectedBone as DirectorHumanoidBone] || [0, 0, 0, 1] as DirectorQuat;
updateObject(selectedObject.id, { boneTracks: upsertDirectorBoneKeyframe(selectedObject.boneTracks || [], selectedBone as DirectorHumanoidBone, playhead, rotation) });
updateObject(selectedObject.id, { boneTracks: upsertDirectorBoneKeyframe(selectedObject.boneTracks || [], selectedBone as DirectorHumanoidBone, snappedPlayhead, rotation) });
return;
}
if (selectedObject) addObjectKeyframe();
else addCameraKeyframe();
};
const handleObjectTransform = useCallback((id: string, transform: DirectorTransform) => {
/** 对象 transform 编辑的唯一入口:gizmo 与检查器共用同一套静态/动画语义。 */
const handleObjectTransform = useCallback((id: string, from: DirectorTransform, to: DirectorTransform) => {
commit((current) => ({
...current,
objects: current.objects.map((item) => item.id === id ? { ...item, transform, keyframes: autoKey ? upsertDirectorKeyframe(item.keyframes, playhead, transform) : item.keyframes } : item),
objects: current.objects.map((item) => {
if (item.id !== id) return item;
const edit = resolveDirectorObjectTransformEdit({ base: item.transform, keyframes: item.keyframes, rendered: from, edited: to, autoKey, time: snappedPlayhead });
return { ...item, transform: edit.transform, keyframes: edit.keyframes };
}),
}));
}, [autoKey, commit, playhead]);
}, [autoKey, commit, snappedPlayhead]);
const handleBoneTransform = useCallback((id: string, bone: string, rotation: DirectorQuat) => {
commit((current) => ({
/** 骨骼写入语义:静态覆盖 + autoKey 时在吸附播放头补关键帧。gizmo 与数值编辑器共用。 */
const writeBoneRotation = useCallback((id: string, bone: string, rotation: DirectorQuat, mode: "stage" | "commit") => {
const write = mode === "stage" ? stageGesture : commit;
write((current) => ({
...current,
objects: current.objects.map((item) => item.id === id ? {
...item,
boneOverrides: { ...item.boneOverrides, [bone]: rotation },
boneTracks: autoKey ? upsertDirectorBoneKeyframe(item.boneTracks || [], bone as DirectorHumanoidBone, playhead, rotation) : item.boneTracks,
boneTracks: autoKey ? upsertDirectorBoneKeyframe(item.boneTracks || [], bone as DirectorHumanoidBone, snappedPlayhead, rotation) : item.boneTracks,
} : item),
}));
}, [autoKey, commit, playhead]);
}, [autoKey, commit, snappedPlayhead, stageGesture]);
const handleBoneTransform = useCallback((id: string, bone: string, rotation: DirectorQuat) => writeBoneRotation(id, bone, rotation, "commit"), [writeBoneRotation]);
const handleActorRigReady = useCallback((id: string, rig: DirectorRig, animations: AnimationClip[]) => {
replaceWithoutHistory((current) => ({
@@ -277,14 +530,16 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
};
const applyToCanvas = async () => {
if (!draft || !activeShot || !viewportRef.current) return;
stagedTransaction.end("commit");
const current = draftRef.current;
if (!current || !activeShot || !viewportRef.current) return;
setSaving(true);
try {
const beauty = await viewportRef.current.capture("beauty");
const prompt = compileDirectorPrompt(draft, activeShot);
const next = touchDirectorScene(draft);
setDraft(next);
onChange(next);
const prompt = compileDirectorPrompt(current, activeShot);
// 先镜像最新 scene,再做 canvas 输出;失败时 draft 保留可继续重试。
const next = touchDirectorScene(current);
writeAndPublish(next);
await onApply({ scene: next, shot: activeShot, prompt, beauty });
message.success("导演台构图已回写画布");
} catch (error) {
@@ -295,7 +550,9 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
};
const exportClayVideo = async () => {
if (!draft || !activeShot || !viewportRef.current || recording) return;
stagedTransaction.end("commit");
const current = draftRef.current;
if (!current || !activeShot || !viewportRef.current || recording) return;
setRecording(true);
const wasPlaying = playing;
const previousPlayhead = playhead;
@@ -304,8 +561,8 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
try {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const clayVideo = await viewportRef.current.recordVideo(activeShot.duration, activeShot.fps);
const next = touchDirectorScene(draft);
onChange(next);
const next = touchDirectorScene(draftRef.current || current);
writeAndPublish(next);
await onApply({ scene: next, shot: activeShot, prompt: compileDirectorPrompt(next, activeShot), beauty: await viewportRef.current.capture("beauty"), clayVideo, clayVideoMimeType: clayVideo.type });
message.success("白膜视频已回写画布");
} catch (error) {
@@ -322,12 +579,22 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
return (
<div data-canvas-no-zoom className="fixed inset-0 z-[var(--z-toast)] flex min-h-0 flex-col overflow-hidden" style={{ background: theme.canvas.background, color: theme.node.text }}>
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-2" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
<IconButton label="关闭导演台" onClick={onClose}><X className="size-4" /></IconButton>
<IconButton label="关闭导演台" onClick={closeWorkbench}><X className="size-4" /></IconButton>
<Input variant="borderless" value={draft.title} className="max-w-56 font-medium" onChange={(event) => replaceWithoutHistory((current) => ({ ...current, title: event.target.value }))} />
<span className="h-5 w-px" style={{ background: theme.toolbar.border }} />
<IconButton label="撤销" disabled={!history.length} onClick={undo}><Undo2 className="size-4" /></IconButton>
<IconButton label="重做" disabled={!future.length} onClick={redo}><Redo2 className="size-4" /></IconButton>
<div className="ml-auto flex items-center gap-1">
<div className="ml-auto flex items-center gap-2">
<span
aria-live="polite"
className="text-[var(--fs-tiny)]"
style={{ color: saveIndicator.tone === "danger" ? "var(--status-error)" : undefined, opacity: saveIndicator.tone === "idle" ? 0.55 : 1 }}
>
{saveIndicator.label}
</span>
{saveIndicator.retryable ? <Button size="small" icon={<RotateCcw className="size-3.5" />} loading={retrying || saveIndicator.busy} onClick={() => void retrySave()}>重试保存</Button> : null}
</div>
<div className="flex items-center gap-1">
<Select size="small" value={renderMode} className="w-24" options={[{ label: "预览", value: "beauty" }, { label: "彩色白膜", value: "clay" }, { label: "骨骼", value: "pose" }, { label: "深度", value: "depth" }, { label: "法线", value: "normal" }]} onChange={setRenderMode} />
<Button size="small" icon={<Video className="size-3.5" />} loading={recording} onClick={() => void exportClayVideo()}>导出白膜</Button>
<Button size="small" type="primary" icon={<Save className="size-3.5" />} loading={saving} onClick={() => void applyToCanvas()}>应用到镜头</Button>
@@ -359,13 +626,13 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
</aside>
<main className="relative min-h-0 overflow-hidden bg-neutral-900">
<DirectorViewport ref={viewportRef} scene={draft} selectedObjectId={selectedObjectId} selectedBone={selectedBone} transformMode={transformMode} renderMode={renderMode} playhead={playhead} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onObjectTransform={handleObjectTransform} onBoneTransform={handleBoneTransform} onActorRigReady={handleActorRigReady} />
<DirectorViewport ref={viewportRef} scene={draft} selectedObjectId={selectedObjectId} selectedBone={selectedBone} transformMode={transformMode} renderMode={renderMode} playhead={playhead} playing={playing} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onObjectTransform={handleObjectTransform} onBoneTransform={handleBoneTransform} onActorRigReady={handleActorRigReady} />
<div className="pointer-events-none absolute left-3 top-3 text-[var(--fs-tiny)] font-medium text-white/70">{activeShot.name} · {activeCamera?.name || "无摄影机"} · {activeShot.duration}s</div>
<DirectorViewportDock transformMode={transformMode} renderMode={renderMode} onTransformModeChange={setTransformMode} onRenderModeChange={setRenderMode} onAddActor={addActor} onAddBox={() => addPrimitive("box", "立方体")} onAddLight={addLight} onAddCamera={addCamera} onAlignCamera={alignCameraToView} />
</main>
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-l max-lg:hidden" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
{selectedObject ? <ObjectInspector object={selectedObject} playhead={playhead} selectedBone={selectedBone} autoKey={autoKey} onSelectBone={setSelectedBone} onUpdate={(patch) => updateObject(selectedObject.id, patch)} onAddKeyframe={recordSelectedKeyframe} onDelete={() => removeObject(selectedObject.id)} /> : selectedLight ? <LightInspector light={selectedLight} onUpdate={(patch) => updateLight(selectedLight.id, patch)} onDelete={() => removeLight(selectedLight.id)} /> : <ShotInspector shot={activeShot} camera={activeCamera} cameras={draft.cameras} onUpdateShot={(patch) => updateShot(activeShot.id, patch)} onUpdateCamera={(patch) => activeCamera && commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, ...patch } : item) }))} onAddCameraKeyframe={addCameraKeyframe} onApplyCameraMove={applyCameraMove} onAlignCameraToView={alignCameraToView} onExportClay={exportClayVideo} recording={recording} />}
{selectedObject ? <ObjectInspector object={selectedObject} rendered={selectedObjectRendered || selectedObject.transform} playhead={snappedPlayhead} selectedBone={selectedBone} onSelectBone={setSelectedBone} onUpdate={(patch) => updateObject(selectedObject.id, patch)} onTransformEdit={(edited) => handleObjectTransform(selectedObject.id, selectedObjectRendered || selectedObject.transform, edited)} onBoneRotationStage={(rotation) => selectedBone && writeBoneRotation(selectedObject.id, selectedBone, rotation, "stage")} onBoneRotationCommit={() => stagedTransaction.end("commit")} onAddKeyframe={recordSelectedKeyframe} onDelete={() => removeObject(selectedObject.id)} /> : selectedLight ? <LightInspector light={selectedLight} onUpdate={(patch) => updateLight(selectedLight.id, patch)} onDelete={() => removeLight(selectedLight.id)} /> : <ShotInspector shot={activeShot} camera={activeCamera} cameras={draft.cameras} onUpdateShot={(patch) => updateShot(activeShot.id, patch)} onUpdateCamera={(patch) => activeCamera && commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? { ...item, ...patch } : item) }))} onAddCameraKeyframe={addCameraKeyframe} onApplyCameraMove={applyCameraMove} onAlignCameraToView={alignCameraToView} onExportClay={() => void exportClayVideo()} recording={recording} />}
</aside>
</div>
@@ -374,22 +641,16 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onClose, onCh
);
}
function ObjectInspector({ object, playhead, selectedBone, autoKey, onSelectBone, onUpdate, onAddKeyframe, onDelete }: { object: DirectorObject; playhead: number; selectedBone: string | null; autoKey: boolean; onSelectBone: (bone: string | null) => void; onUpdate: (patch: Partial<DirectorObject>) => void; onAddKeyframe: () => void; onDelete: () => void }) {
function ObjectInspector({ object, rendered, playhead, selectedBone, onSelectBone, onUpdate, onTransformEdit, onBoneRotationStage, onBoneRotationCommit, onAddKeyframe, onDelete }: { object: DirectorObject; rendered: DirectorTransform; playhead: number; selectedBone: string | null; onSelectBone: (bone: string | null) => void; onUpdate: (patch: Partial<DirectorObject>) => void; onTransformEdit: (transform: DirectorTransform) => void; onBoneRotationStage: (rotation: DirectorQuat) => void; onBoneRotationCommit: () => void; onAddKeyframe: () => void; onDelete: () => void }) {
const motionClips = object.motionClips || [];
const activeMotionClip = motionClips.find((clip) => clip.id === object.activeMotionClipId);
const mappedBones = Object.keys(object.rig?.boneMap || {}) as DirectorHumanoidBone[];
const selectedBoneId = selectedBone as DirectorHumanoidBone | null;
const selectedBoneRotation = selectedBoneId ? object.boneOverrides?.[selectedBoneId] || [0, 0, 0, 1] as DirectorQuat : null;
const updateActiveMotion = (patch: Partial<NonNullable<DirectorObject["motionClips"]>[number]>) => activeMotionClip && onUpdate({ motionClips: motionClips.map((clip) => clip.id === activeMotionClip.id ? { ...clip, ...patch } : clip) });
const updateSelectedBoneRotation = (rotation: DirectorQuat) => {
if (!selectedBoneId) return;
const patch: Partial<DirectorObject> = { boneOverrides: { ...object.boneOverrides, [selectedBoneId]: rotation } };
if (autoKey) patch.boneTracks = upsertDirectorBoneKeyframe(object.boneTracks || [], selectedBoneId, playhead, rotation);
onUpdate(patch);
};
const applyPose = (pose: DirectorPose) => onUpdate({ pose, activeMotionClipId: undefined, boneOverrides: {} });
return <Inspector title={object.name} onTitleChange={(name) => onUpdate({ name })} onDelete={onDelete}>
<TransformFields transform={object.transform} onChange={(transform) => onUpdate({ transform })} />
<TransformFields transform={rendered} onChange={onTransformEdit} />
{object.kind === "actor" || object.primitive === "character"
? <Field label="角色颜色"><div className="director-actor-colors">{DIRECTOR_ACTOR_COLORS.map((color) => <button key={color} type="button" className={`director-actor-color ${object.color.toLowerCase() === color ? "is-active" : ""}`} style={{ background: color }} aria-label={`设置颜色 ${color}`} onClick={() => onUpdate({ color })} />)}<ColorPicker value={object.color} size="small" onChange={(_, color) => onUpdate({ color })} /></div></Field>
: <Field label="颜色"><ColorPicker value={object.color} onChange={(_, color) => onUpdate({ color })} /></Field>}
@@ -401,7 +662,7 @@ function ObjectInspector({ object, playhead, selectedBone, autoKey, onSelectBone
<div className="flex items-center justify-between border-y py-2 text-[var(--fs-label)]"><span>角色绑定</span><span className="opacity-55">{object.rig?.status === "ready" ? `${mappedBones.length} 根骨骼` : "等待模型"}</span></div>
{motionClips.length ? <><Field label="动作片段"><Select className="w-full" value={object.activeMotionClipId || ""} options={[{ label: "静态姿势", value: "" }, ...motionClips.map((clip) => ({ label: clip.name, value: clip.id }))]} onChange={(activeMotionClipId) => onUpdate({ activeMotionClipId: activeMotionClipId || undefined })} /></Field>{activeMotionClip ? <div className="grid grid-cols-2 gap-2"><Field label="播放速度"><InputNumber className="w-full" min={0.1} max={4} step={0.1} value={activeMotionClip.playbackRate} onChange={(playbackRate) => updateActiveMotion({ playbackRate: playbackRate || 1 })} /></Field><Field label="循环"><Switch checked={activeMotionClip.loop} onChange={(loop) => updateActiveMotion({ loop })} /></Field></div> : null}</> : <div className="text-[var(--fs-tiny)] opacity-50">模型加载后会显示可用动作 Clip</div>}
{mappedBones.length ? <Field label="骨骼控制"><Select className="w-full" allowClear value={selectedBone || undefined} options={mappedBones.map((bone) => ({ label: directorBoneLabel(bone), value: bone }))} onChange={(bone) => onSelectBone(bone || null)} /></Field> : null}
{selectedBoneId && selectedBoneRotation ? <BoneRotationFields rotation={selectedBoneRotation} onChange={updateSelectedBoneRotation} /> : null}
{selectedBoneId && selectedBoneRotation ? <BoneRotationFields rotation={selectedBoneRotation} onChange={onBoneRotationStage} onChangeComplete={onBoneRotationCommit} /> : null}
</> : null}
<Field label="可见"><Switch checked={object.visible} onChange={(visible) => onUpdate({ visible })} /></Field>
<Field label="投射阴影"><Switch checked={object.castShadow} onChange={(castShadow) => onUpdate({ castShadow })} /></Field>
@@ -433,7 +694,7 @@ function TransformFields({ transform, onChange }: { transform: DirectorTransform
return <><Vec3Field label="位置" value={transform.position} onChange={(position) => onChange({ ...transform, position })} /><Vec3Field label="旋转" value={transform.rotation} step={0.05} onChange={(rotation) => onChange({ ...transform, rotation })} /><Vec3Field label="缩放" value={transform.scale} step={0.1} onChange={(scale) => onChange({ ...transform, scale })} /></>;
}
function BoneRotationFields({ rotation, onChange }: { rotation: DirectorQuat; onChange: (rotation: DirectorQuat) => void }) {
function BoneRotationFields({ rotation, onChange, onChangeComplete }: { rotation: DirectorQuat; onChange: (rotation: DirectorQuat) => void; onChangeComplete: () => void }) {
const initialDegrees = useMemo(() => {
const euler = new Euler().setFromQuaternion(new Quaternion(...rotation), "XYZ");
return [euler.x, euler.y, euler.z].map((value) => Number(((value * 180) / Math.PI).toFixed(1))) as DirectorVec3;
@@ -458,7 +719,7 @@ function BoneRotationFields({ rotation, onChange }: { rotation: DirectorQuat; on
return <Field label="骨骼旋转(局部角度 °)"><div className="space-y-1.5">
{degrees.map((value, index) => <div key={index} className="grid grid-cols-[18px_minmax(0,1fr)_48px] items-center gap-2">
<span className="text-[var(--fs-tiny)] font-medium opacity-65">{["X", "Y", "Z"][index]}</span>
<Slider className="m-0" min={-180} max={180} step={1} value={value} onChange={(next) => updateAxis(index, Array.isArray(next) ? next[0] ?? 0 : next)} />
<Slider className="m-0" min={-180} max={180} step={1} value={value} onChange={(next) => updateAxis(index, Array.isArray(next) ? next[0] ?? 0 : next)} onChangeComplete={onChangeComplete} />
<span className="text-right text-[var(--fs-tiny)] tabular-nums opacity-65">{value.toFixed(1)}°</span>
</div>)}
</div></Field>;

View File

@@ -1,18 +1,29 @@
import { Canvas, useFrame, useThree, type ThreeEvent } from "@react-three/fiber";
import { Grid, OrbitControls, TransformControls } from "@react-three/drei";
import { forwardRef, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { AnimationClip, AnimationMixer, Box3, Bone, Camera, Color, Group, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshNormalMaterial, MeshStandardMaterial, Object3D, OrthographicCamera, PerspectiveCamera, Quaternion, Scene, SkeletonHelper, Texture, TextureLoader, Vector3, WebGLRenderer } from "three";
import { Component, forwardRef, memo, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useReducer, useRef, useState, type ComponentRef, type ReactNode } from "react";
import { AnimationClip, AnimationMixer, Box3, Bone, Camera, Color, Group, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, MeshDepthMaterial, MeshNormalMaterial, MeshStandardMaterial, Object3D, OrthographicCamera, PerspectiveCamera, Plane, Quaternion, Raycaster, Scene, SkeletonHelper, Texture, TextureLoader, Vector2, Vector3, WebGLRenderer } from "three";
import type { Material } from "three";
import { GLTFLoader, SkeletonUtils } from "three-stdlib";
import { DIRECTOR_DEFAULT_ACTOR_URL, directorPoseBoneDeltas, interpolateDirectorBoneRotation, interpolateDirectorTransform } from "@/lib/canvas/director/director-scene";
import { directorCameraSyncKey, resolveDirectorBoneRotation } from "@/lib/canvas/director/director-animation-semantics";
import { createDirectorTransaction, installDirectorTerminalListeners } from "@/lib/canvas/director/director-gesture-transaction";
import { emptyDirectorPlacementIntent, finiteDirectorGroundPoint, type DirectorGroundPoint, type DirectorPlacementIntent } from "@/lib/canvas/director/director-placement";
import { directorDiagnosticObjectKind } from "@/lib/canvas/director/director-diagnostics";
import { recordDirectorDiagnostic } from "@/lib/canvas/director/director-diagnostics-recorder";
import { directorCaptureInitial, directorCaptureUsable, directorLoadIdentity, directorLoadInitial, installDirectorContextListeners, reduceDirectorCapture, reduceDirectorLoad, releaseDirectorCapture, resolveDirectorDisplay, restoreDirectorCapture, upsertDirectorFailedLoad, type DirectorFailedLoads, type DirectorLoadSignal } from "@/lib/canvas/director/director-recovery";
import { disposeDirectorAdoptionFailure, disposeDirectorHelper, disposeDirectorMaterials, disposeDirectorModelResources, disposeDirectorObject3D, resolveDirectorLoadOwnership } from "@/lib/canvas/director/director-resources";
import { DIRECTOR_DEFAULT_ACTOR_URL, directorPoseBoneDeltas, interpolateDirectorTransform } from "@/lib/canvas/director/director-scene";
import { resolveMediaUrl } from "@/services/file-storage";
import type { DirectorCamera, DirectorHumanoidBone, DirectorLight, DirectorObject, DirectorQuat, DirectorRenderMode, DirectorRig, DirectorScene, DirectorTransform, DirectorVec3 } from "@/types/director";
export type DirectorOrbitControls = ComponentRef<typeof OrbitControls>;
export type DirectorViewportHandle = {
capture: (mode: DirectorRenderMode) => Promise<Blob>;
recordVideo: (duration: number, fps: number) => Promise<Blob>;
readCameraTransform: () => DirectorTransform | null;
/** 只读放置意图。上下文不可用或从未产生合法点时返回空意图,绝不抛异常。 */
readPlacementIntent: () => DirectorPlacementIntent;
};
type DirectorViewportProps = {
@@ -22,45 +33,246 @@ type DirectorViewportProps = {
transformMode: "translate" | "rotate" | "scale";
renderMode: DirectorRenderMode;
playhead: number;
playing: boolean;
onSelectObject: (id: string | null) => void;
onSelectBone: (bone: string | null) => void;
onObjectTransform: (id: string, transform: DirectorTransform) => void;
onObjectTransform: (id: string, from: DirectorTransform, to: DirectorTransform) => void;
onBoneTransform: (id: string, bone: string, rotation: DirectorQuat) => void;
onActorRigReady: (id: string, rig: DirectorRig, animations: AnimationClip[]) => void;
};
type CaptureContext = { gl: WebGLRenderer; scene: Scene; camera: PerspectiveCamera; suspendDisplayMaterialOverride: () => () => void };
// 稳定空值:identity 不匹配时返回同一引用,避免下游 effect 依赖每次 render 都变化。
const emptyAnimations: AnimationClip[] = [];
const emptyRestRotations: Partial<Record<DirectorHumanoidBone, DirectorQuat>> = {};
// Canvas 配置必须是稳定引用:inline literal 每次父级 render 都是新对象,
// context lost 的 dispatch 触发重渲染后 R3F 会 configure 并在失效 context 上
// 重建 WebGLRenderer,抛 getMaxPrecision / autoReset。稳定后 lost 只显示 notice。
const directorCanvasGl = { antialias: true, preserveDrawingBuffer: true, alpha: false } as const;
const directorCanvasCamera = { position: [4.8, 2.7, 6.8] as [number, number, number], fov: 50, near: 0.05, far: 500 } as const;
const directorCanvasDpr: [number, number] = [1, 1.5];
export const DirectorViewport = forwardRef<DirectorViewportHandle, DirectorViewportProps>(function DirectorViewport(props, ref) {
const captureContext = useRef<CaptureContext | null>(null);
const onCaptureContext = useCallback((context: CaptureContext) => { captureContext.current = context; }, []);
// 地面点连同 owner canvas 一起记录:owner 不是当前 renderer 的 canvas 就是陈旧值。
const groundRef = useRef<{ owner: HTMLCanvasElement; point: DirectorGroundPoint } | null>(null);
const orbitControlsRef = useRef<DirectorOrbitControls | null>(null);
/** pointermove 高频路径只写 ref,不触发 render;清空只允许由该点的 owner 发起。 */
const onGroundPoint = useCallback((owner: HTMLCanvasElement, point: DirectorGroundPoint | null) => {
if (point) {
groundRef.current = { owner, point };
return;
}
if (groundRef.current?.owner === owner) groundRef.current = null;
}, []);
const onOrbitControls = useCallback((controls: DirectorOrbitControls | null) => {
orbitControlsRef.current = controls;
}, []);
// retryKey 变化会真正重建 Canvas 与 ErrorBoundary,而不是只换文案。
const [retryKey, setRetryKey] = useState(0);
// capture 可用性:上下文丢失期间不得再使用失效 renderer;恢复后需重新登记。
const [capture, dispatchCapture] = useReducer(reduceDirectorCapture, directorCaptureInitial);
const captureRef = useRef(capture);
captureRef.current = capture;
// 加载失败的对象 id -> 该对象自己的 retry;Canvas 内部无法呈现可操作提示,统一提到 DOM 层。
const [failedLoads, setFailedLoads] = useState<DirectorFailedLoads>({});
const onCaptureContext = useCallback((context: CaptureContext) => {
captureContext.current = context;
dispatchCapture("register");
}, []);
/** 子树卸载 / boundary 捕获:清掉指向已销毁 renderer 的引用并打回不可用。 */
const releaseCapture = useCallback(() => {
releaseDirectorCapture({
clearContext: () => {
captureContext.current = null;
},
onAvailability: dispatchCapture,
});
// 地面点与 controls 都属于已销毁的 renderer,必须一并作废。
groundRef.current = null;
orbitControlsRef.current = null;
}, []);
const retry = useCallback(() => {
releaseCapture();
setFailedLoads({});
setRetryKey((value) => value + 1);
}, [releaseCapture]);
const onLoadStateChange = useCallback((id: string, signal: DirectorLoadSignal, retryLoad: () => void) => {
setFailedLoads((current) => upsertDirectorFailedLoad(current, id, signal, retryLoad));
}, []);
// 稳定引用:否则新的监听 effect 会在每次父级 render 时摘除重装。
const onContextLost = useCallback(() => {
// 上下文丢失后相机与射线结果都不可信,旧地面点必须作废,恢复后需重新移动 pointer。
groundRef.current = null;
recordDirectorDiagnostic("DIRECTOR_VIEWPORT_CONTEXT_LOST");
dispatchCapture("lost");
}, []);
const onContextRestored = useCallback(() => {
recordDirectorDiagnostic("DIRECTOR_VIEWPORT_CONTEXT_RESTORED");
dispatchCapture("restored");
}, []);
const failedIds = Object.keys(failedLoads);
// 上下文失效时 renderer 不可用:capture/record/readCamera 必须明确失败而不是画出脏帧。
const usableContext = () => {
if (!directorCaptureUsable(captureRef.current)) return null;
return captureContext.current;
};
useImperativeHandle(ref, () => ({
capture: (mode) => captureFrame(captureContext.current, mode),
recordVideo: (duration, fps) => recordCanvas(captureContext.current, duration, fps),
capture: (mode) => captureFrame(usableContext(), mode),
recordVideo: (duration, fps) => recordCanvas(usableContext(), duration, fps),
readCameraTransform: () => {
const camera = captureContext.current?.camera;
const camera = usableContext()?.camera;
return camera ? { position: camera.position.toArray() as DirectorTransform["position"], rotation: [camera.rotation.x, camera.rotation.y, camera.rotation.z], scale: [1, 1, 1] } : null;
},
readPlacementIntent: () => {
const context = usableContext();
if (!context) return emptyDirectorPlacementIntent;
// owner 校验:上下文重建后,旧 renderer canvas 记录的点一律不采用。
const tracked = groundRef.current;
const pointer = tracked && tracked.owner === context.gl.domElement ? tracked.point : null;
// 读实例当前 target,而不是 activeCamera.target prop;投影到 y=0 即取 x/z。
const target = orbitControlsRef.current?.target;
return { pointer, orbitTarget: finiteDirectorGroundPoint(target?.x, target?.z) };
},
}), []);
return (
// data-renderer-ready 直接来自 directorCaptureUsable:capture context 已登记且未 lost。
// 这是真实就绪信号,供 E2E 在触发 context loss 前确定监听器已安装。
<div className="director-viewport-shell" data-renderer-ready={directorCaptureUsable(capture) ? "true" : "false"}>
<DirectorViewportErrorBoundary key={`boundary-${retryKey}`} onRelease={releaseCapture} onRetry={retry}>
<DirectorCanvasSurface
key={`canvas-${retryKey}`}
{...props}
onCaptureContext={onCaptureContext}
onRelease={releaseCapture}
onContextLost={onContextLost}
onContextRestored={onContextRestored}
onLoadStateChange={onLoadStateChange}
onGroundPoint={onGroundPoint}
onOrbitControls={onOrbitControls}
/>
</DirectorViewportErrorBoundary>
{capture.contextLost ? (
<DirectorViewportNotice
title="3D 显示上下文已丢失"
description="浏览器回收了 WebGL 上下文。等待自动恢复,或立即重建视口。"
actionLabel="重建 3D 视口"
onAction={retry}
/>
) : null}
{!capture.contextLost && failedIds.length ? (
<DirectorViewportNotice
variant="corner"
title={`${failedIds.length} 个 3D 模型加载失败`}
description="已用占位人偶继续显示场景。可能是网络或模型地址不可用,重试将重新加载。"
actionLabel="重试加载"
onAction={() => {
// 重试触发新的 load generation,旧的晚到回调会被忽略并释放资源。
Object.values(failedLoads).forEach((retryLoad) => retryLoad());
setFailedLoads({});
}}
/>
) : null}
</div>
);
});
type DirectorCanvasSurfaceProps = DirectorViewportProps & {
onCaptureContext: (context: CaptureContext) => void;
onRelease: () => void;
onContextLost: () => void;
onContextRestored: () => void;
onLoadStateChange: (id: string, signal: DirectorLoadSignal, retry: () => void) => void;
onGroundPoint: (owner: HTMLCanvasElement, point: DirectorGroundPoint | null) => void;
onOrbitControls: (controls: DirectorOrbitControls | null) => void;
};
/**
* Canvas 表面独立 memo。
*
* R3F 本地 CanvasImpl 的 layout effect 没有依赖数组,父级每次 rerender 都会 configure。
* context lost 时 DirectorViewport 会 dispatchCapture 触发 rerender,若 Canvas 跟着重渲染,
* configure 就会在已失效的 canvas 上重建 WebGLRenderer 并抛 getMaxPrecision / autoReset。
* 隔离在 memo 子组件后,contextLost / failedLoads 这类外层状态变化不再穿透到 Canvas;
* 只有 props 真的变化才重渲染,retryKey 变化仍由外层 key 触发真正 remount。
*/
const DirectorCanvasSurface = memo(function DirectorCanvasSurface(props: DirectorCanvasSurfaceProps) {
const { onCaptureContext, onRelease, onContextLost, onContextRestored, onLoadStateChange, onGroundPoint, onOrbitControls, ...sceneProps } = props;
const onSelectObject = sceneProps.onSelectObject;
const onPointerMissed = useCallback(() => onSelectObject(null), [onSelectObject]);
return (
<Canvas
shadows
frameloop="demand"
dpr={[1, 1.5]}
camera={{ position: [4.8, 2.7, 6.8], fov: 50, near: 0.05, far: 500 }}
gl={{ antialias: true, preserveDrawingBuffer: true, alpha: false }}
onPointerMissed={() => props.onSelectObject(null)}
dpr={directorCanvasDpr}
camera={directorCanvasCamera}
gl={directorCanvasGl}
onPointerMissed={onPointerMissed}
>
<Suspense fallback={null}>
<DirectorSceneContent {...props} onCaptureContext={onCaptureContext} />
<DirectorSceneContent
{...sceneProps}
onCaptureContext={onCaptureContext}
onRelease={onRelease}
onContextLost={onContextLost}
onContextRestored={onContextRestored}
onLoadStateChange={onLoadStateChange}
onGroundPoint={onGroundPoint}
onOrbitControls={onOrbitControls}
/>
</Suspense>
</Canvas>
);
});
function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transformMode, renderMode, playhead, onSelectObject, onSelectBone, onObjectTransform, onBoneTransform, onActorRigReady, onCaptureContext }: DirectorViewportProps & { onCaptureContext: (context: CaptureContext) => void }) {
/** 本地失败隔离:3D 视口异常只替换视口本身,不影响画布/项目其余部分。 */
class DirectorViewportErrorBoundary extends Component<{ children: ReactNode; onRelease: () => void; onRetry: () => void }, { failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch() {
// 只记录稳定错误码:原始 error / componentStack 可能带素材 URL 或正文,不落日志。
recordDirectorDiagnostic("DIRECTOR_VIEWPORT_RENDER_FAILED");
// 子树已被 React 卸载,capture context 指向已销毁的 renderer,必须同步释放。
this.props.onRelease();
}
render() {
if (!this.state.failed) return this.props.children;
return (
<DirectorViewportNotice
title="3D 视口渲染失败"
description="导演台其余面板仍可使用。可重试重建视口;若持续失败请检查显卡驱动或浏览器 WebGL 支持。"
actionLabel="重试 3D 视口"
onAction={() => {
this.setState({ failed: false });
this.props.onRetry();
}}
/>
);
}
}
function DirectorViewportNotice({ title, description, actionLabel, onAction, variant = "cover" }: { title: string; description: string; actionLabel: string; onAction: () => void; variant?: "cover" | "corner" }) {
return (
<div className={`director-viewport-notice ${variant === "corner" ? "is-corner" : "is-cover"}`} role="alert">
<div className="director-viewport-notice-title">{title}</div>
<p className="director-viewport-notice-text">{description}</p>
<button type="button" className="director-viewport-notice-action" onClick={onAction}>{actionLabel}</button>
</div>
);
}
function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transformMode, renderMode, playhead, playing, onSelectObject, onSelectBone, onObjectTransform, onBoneTransform, onActorRigReady, onCaptureContext, onRelease, onContextLost, onContextRestored, onLoadStateChange, onGroundPoint, onOrbitControls }: DirectorViewportProps & { onCaptureContext: (context: CaptureContext) => void; onRelease: () => void; onContextLost: () => void; onContextRestored: () => void; onLoadStateChange: (id: string, signal: DirectorLoadSignal, retry: () => void) => void; onGroundPoint: (owner: HTMLCanvasElement, point: DirectorGroundPoint | null) => void; onOrbitControls: (controls: DirectorOrbitControls | null) => void }) {
const { gl, camera, scene: threeScene, invalidate } = useThree();
const orbitRef = useRef<DirectorOrbitControls>(null);
const [transforming, setTransforming] = useState(false);
const displayClayRestoreRef = useRef<(() => void) | null>(null);
const shot = scene.shots.find((item) => item.id === scene.activeShotId) || scene.shots[0];
@@ -74,9 +286,68 @@ function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transform
};
}, [threeScene]);
const readCaptureContext = useCallback((): CaptureContext => ({ gl, camera: camera as PerspectiveCamera, scene: threeScene, suspendDisplayMaterialOverride }), [camera, gl, suspendDisplayMaterialOverride, threeScene]);
useEffect(() => {
onCaptureContext({ gl, camera: camera as PerspectiveCamera, scene: threeScene, suspendDisplayMaterialOverride });
}, [camera, gl, onCaptureContext, suspendDisplayMaterialOverride, threeScene]);
onCaptureContext(readCaptureContext());
}, [onCaptureContext, readCaptureContext]);
// 子树卸载(重试重建、boundary 捕获后 React 卸载)时同步释放:
// 此后 captureContext 指向已销毁的 renderer,必须清空并打回不可用。
// 单独 effect 且只依赖稳定的 onRelease,不会被 readCaptureContext 变化连带触发。
useEffect(() => () => onRelease(), [onRelease]);
// 上下文丢失/恢复监听绑定在 renderer 自己的 canvas 上,随 renderer 与重试精确摘除。
useEffect(() => installDirectorContextListeners(gl.domElement, {
onLost: onContextLost,
// 恢复后 registered 会被置回 false,必须用当前 renderer 重新登记,
// 否则 capture/record/readCameraTransform 会一直不可用。走与测试共用的序列 helper。
onRestored: () => restoreDirectorCapture({
readContext: readCaptureContext,
onAvailability: onContextRestored,
onRegister: onCaptureContext,
invalidate,
}),
}), [gl, invalidate, onCaptureContext, onContextLost, onContextRestored, readCaptureContext]);
/**
* 地面拾取:renderer 自己的 canvas 上做 pointer 监听,再用真实 Raycaster 与
* 世界 y=0 Plane 求交。不走 mesh onPointerMove(会被物体 stopPropagation 截断),
* 也不做 DOM 像素伪换算;因此 pointer 悬停在物体上方时射线仍与地面相交。
* 高频路径只写 ref,绝不 setState。
*/
useEffect(() => {
const canvasElement = gl.domElement;
const raycaster = new Raycaster();
const groundPlane = new Plane(new Vector3(0, 1, 0), 0);
const hit = new Vector3();
const ndc = new Vector2();
const onPointerMove = (event: PointerEvent) => {
const bounds = canvasElement.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) return;
ndc.set(((event.clientX - bounds.left) / bounds.width) * 2 - 1, -((event.clientY - bounds.top) / bounds.height) * 2 + 1);
raycaster.setFromCamera(ndc, camera);
// 射线与地面平行或背离时 intersectPlane 返回 null:保留上一个合法点,不写非法值。
if (!raycaster.ray.intersectPlane(groundPlane, hit)) return;
const point = finiteDirectorGroundPoint(hit.x, hit.z);
if (point) onGroundPoint(canvasElement, point);
};
canvasElement.addEventListener("pointermove", onPointerMove, { passive: true });
return () => {
canvasElement.removeEventListener("pointermove", onPointerMove);
// 这个 renderer 的 canvas 卸载后,它记录的地面点不得再被读到。
onGroundPoint(canvasElement, null);
};
}, [camera, gl, onGroundPoint]);
// OrbitControls 的真实 target 只能从实例读;activeCamera.target 只是初始 prop。
// 挂载期登记一次即可:drei 重建实例会连带重跑本 effect。
useEffect(() => {
onOrbitControls(orbitRef.current);
return () => onOrbitControls(null);
}, [onOrbitControls]);
useEffect(() => {
threeScene.background = new Color(scene.background);
@@ -98,7 +369,7 @@ function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transform
return (
<>
<CameraSync camera={activeCamera} playhead={playhead} />
<CameraSync camera={activeCamera} playhead={playhead} playing={playing} />
<ambientLight intensity={scene.environmentIntensity * 0.35} />
{scene.lights.map((light) => <DirectorLightView key={light.id} light={light} />)}
{scene.gridVisible ? <Grid position={[0, 0, 0]} infiniteGrid fadeDistance={40} fadeStrength={5} cellSize={0.5} sectionSize={5} cellColor="#8f99a3" sectionColor="#626d77" /> : null}
@@ -117,21 +388,24 @@ function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transform
onSelect={() => onSelectObject(object.id)}
onSelectBone={(bone) => { onSelectObject(object.id); onSelectBone(bone); }}
onTransforming={setTransforming}
onTransform={(transform) => onObjectTransform(object.id, transform)}
onTransform={(from, to) => onObjectTransform(object.id, from, to)}
onBoneTransform={(bone, rotation) => onBoneTransform(object.id, bone, rotation)}
onActorRigReady={(rig, animations) => onActorRigReady(object.id, rig, animations)}
onLoadStateChange={onLoadStateChange}
/>
))}
<OrbitControls makeDefault enabled={!transforming} target={activeCamera?.target || [0, 1, 0]} minDistance={0.6} maxDistance={80} />
<OrbitControls ref={orbitRef} makeDefault enabled={!transforming} target={activeCamera?.target || [0, 1, 0]} minDistance={0.6} maxDistance={80} />
</>
);
}
function CameraSync({ camera, playhead }: { camera?: DirectorCamera; playhead: number }) {
function CameraSync({ camera, playhead, playing }: { camera?: DirectorCamera; playhead: number; playing: boolean }) {
const threeCamera = useThree((state) => state.camera as PerspectiveCamera);
const invalidate = useThree((state) => state.invalidate);
// 只有相机真正被关键帧/播放驱动时才写入,暂停时用户 Orbit 不会被拉回。
const syncKey = directorCameraSyncKey({ camera, playhead, playing });
useEffect(() => {
if (!camera) return;
if (!camera || !syncKey) return;
const transform = interpolateDirectorTransform(camera.transform, camera.keyframes, playhead);
threeCamera.position.set(...transform.position);
threeCamera.rotation.set(...transform.rotation);
@@ -141,47 +415,111 @@ function CameraSync({ camera, playhead }: { camera?: DirectorCamera; playhead: n
threeCamera.lookAt(...camera.target);
threeCamera.updateProjectionMatrix();
invalidate();
}, [camera, invalidate, playhead, threeCamera]);
// camera/playhead 只通过 syncKey 参与依赖,避免草稿对象身份变化触发无谓回写。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invalidate, syncKey, threeCamera]);
return null;
}
function DirectorObjectView({ object, selected, selectedBone, transformMode, playhead, onSelect, onSelectBone, onTransforming, onTransform, onBoneTransform, onActorRigReady }: { object: DirectorObject; selected: boolean; selectedBone: string | null; transformMode: DirectorViewportProps["transformMode"]; playhead: number; onSelect: () => void; onSelectBone: (bone: string | null) => void; onTransforming: (value: boolean) => void; onTransform: (transform: DirectorTransform) => void; onBoneTransform: (bone: string, rotation: DirectorQuat) => void; onActorRigReady: (rig: DirectorRig, animations: AnimationClip[]) => void }) {
const groupRef = useRef<Group>(null);
const transform = interpolateDirectorTransform(object.transform, object.keyframes, playhead);
const content = (
<group
ref={groupRef}
position={transform.position}
rotation={transform.rotation}
scale={transform.scale}
onPointerDown={(event) => {
event.stopPropagation();
onSelect();
}}
>
<DirectorObjectVisual object={object} selected={selected} selectedBone={selectedBone} playhead={playhead} onSelectBone={onSelectBone} onBoneTransform={onBoneTransform} onActorRigReady={onActorRigReady} />
</group>
);
if (!selected) return content;
function DirectorObjectView({ object, selected, selectedBone, transformMode, playhead, onSelect, onSelectBone, onTransforming, onTransform, onBoneTransform, onActorRigReady, onLoadStateChange }: { object: DirectorObject; selected: boolean; selectedBone: string | null; transformMode: DirectorViewportProps["transformMode"]; playhead: number; onSelect: () => void; onSelectBone: (bone: string | null) => void; onTransforming: (value: boolean) => void; onTransform: (from: DirectorTransform, to: DirectorTransform) => void; onBoneTransform: (bone: string, rotation: DirectorQuat) => void; onActorRigReady: (rig: DirectorRig, animations: AnimationClip[]) => void; onLoadStateChange: (id: string, signal: DirectorLoadSignal, retry: () => void) => void }) {
const [target, setTarget] = useState<Group | null>(null);
const resolved = interpolateDirectorTransform(object.transform, object.keyframes, playhead);
// 手势进行中冻结声明式 transform,交由 gizmo 直接改写 Object3D;终态后再由场景状态接管。
const [frozen, setFrozen] = useState<DirectorTransform | null>(null);
const transform = frozen || resolved;
return (
<TransformControls
mode={transformMode}
size={0.8}
onMouseDown={() => onTransforming(true)}
onMouseUp={() => {
onTransforming(false);
const target = groupRef.current;
if (!target) return;
onTransform({ position: target.position.toArray() as DirectorTransform["position"], rotation: [target.rotation.x, target.rotation.y, target.rotation.z], scale: target.scale.toArray() as DirectorTransform["scale"] });
}}
>
{content}
</TransformControls>
<>
<group
ref={setTarget}
position={transform.position}
rotation={transform.rotation}
scale={transform.scale}
onPointerDown={(event) => {
event.stopPropagation();
onSelect();
}}
>
<DirectorObjectVisual object={object} selected={selected} selectedBone={selectedBone} playhead={playhead} onSelectBone={onSelectBone} onBoneTransform={onBoneTransform} onActorRigReady={onActorRigReady} onLoadStateChange={onLoadStateChange} />
</group>
{selected && target ? (
<DirectorObjectGizmo
target={target}
transformMode={transformMode}
onFreeze={setFrozen}
onTransforming={onTransforming}
onTransform={onTransform}
/>
) : null}
</>
);
}
function DirectorObjectVisual({ object, selected, selectedBone, playhead, onSelectBone, onBoneTransform, onActorRigReady }: { object: DirectorObject; selected: boolean; selectedBone: string | null; playhead: number; onSelectBone: (bone: string | null) => void; onBoneTransform: (bone: string, rotation: DirectorQuat) => void; onActorRigReady: (rig: DirectorRig, animations: AnimationClip[]) => void }) {
if ((object.kind === "model" || object.kind === "actor" || object.primitive === "character") && (object.url || object.primitive === "character")) return <DirectorModel object={object} selected={selected} selectedBone={selectedBone} playhead={playhead} onSelectBone={onSelectBone} onBoneTransform={onBoneTransform} onActorRigReady={onActorRigReady} />;
function DirectorObjectGizmo({ target, transformMode, onFreeze, onTransforming, onTransform }: { target: Group; transformMode: DirectorViewportProps["transformMode"]; onFreeze: (transform: DirectorTransform | null) => void; onTransforming: (value: boolean) => void; onTransform: (from: DirectorTransform, to: DirectorTransform) => void }) {
const transaction = useDirectorGizmoTransaction<DirectorTransform>({
read: () => readObject3DTransform(target),
restore: (snapshot) => applyObject3DTransform(target, snapshot),
commit: onTransform,
onActive: (active, snapshot) => {
onFreeze(active ? snapshot : null);
onTransforming(active);
},
});
return (
<TransformControls
object={target}
mode={transformMode}
size={0.8}
onMouseDown={() => transaction.begin()}
onMouseUp={() => transaction.end("commit")}
/>
);
}
/**
* gizmo 事务的统一接线:对象与骨骼共用,避免两套实现产生偏差。
* 监听在挂载期间常驻安装(不以「当前是否有手势」为安装条件),
* 非活跃时 end 自身是空操作。
*/
function useDirectorGizmoTransaction<TSnapshot>({ read, restore, commit, onActive }: { read: () => TSnapshot | null; restore: (snapshot: TSnapshot) => void; commit: (from: TSnapshot, to: TSnapshot) => void; onActive: (active: boolean, snapshot: TSnapshot | null) => void }) {
const hooksRef = useRef({ read, restore, commit, onActive });
useEffect(() => { hooksRef.current = { read, restore, commit, onActive }; }, [commit, onActive, read, restore]);
const transaction = useMemo(() => createDirectorTransaction<TSnapshot>({
read: () => hooksRef.current.read(),
restore: (snapshot) => hooksRef.current.restore(snapshot),
commit: (from, to) => hooksRef.current.commit(from, to),
setActive: (active) => hooksRef.current.onActive(active, active ? hooksRef.current.read() : null),
// stdlib 在 domElement.ownerDocument 上注册 pointerup,其 pointerUp({button:0})
// 会 dispatch mouseUp 后清 dragging=false / axis=null。因此用真实事件走它自己的
// 收尾通道,而不是写它的私有字段(.d.ts 中 dragging/axis 均为 private)。
terminateDrag: () => document.dispatchEvent(new PointerEvent("pointerup", { button: 0, bubbles: true })),
}), []);
useEffect(() => installDirectorTerminalListeners(transaction, {
window,
document,
isHidden: () => document.visibilityState === "hidden",
}), [transaction]);
// 取消选中、删除对象、切换模型或卸载都必须回到快照并释放 transforming。
useEffect(() => () => transaction.end("cancel"), [transaction]);
return transaction;
}
function readObject3DTransform(target: Object3D): DirectorTransform {
return { position: target.position.toArray() as DirectorVec3, rotation: [target.rotation.x, target.rotation.y, target.rotation.z], scale: target.scale.toArray() as DirectorVec3 };
}
function applyObject3DTransform(target: Object3D, transform: DirectorTransform) {
target.position.set(...transform.position);
target.rotation.set(...transform.rotation);
target.scale.set(...transform.scale);
target.updateMatrixWorld(true);
}
function DirectorObjectVisual({ object, selected, selectedBone, playhead, onSelectBone, onBoneTransform, onActorRigReady, onLoadStateChange }: { object: DirectorObject; selected: boolean; selectedBone: string | null; playhead: number; onSelectBone: (bone: string | null) => void; onBoneTransform: (bone: string, rotation: DirectorQuat) => void; onActorRigReady: (rig: DirectorRig, animations: AnimationClip[]) => void; onLoadStateChange: (id: string, signal: DirectorLoadSignal, retry: () => void) => void }) {
if ((object.kind === "model" || object.kind === "actor" || object.primitive === "character") && (object.url || object.primitive === "character")) return <DirectorModel object={object} selected={selected} selectedBone={selectedBone} playhead={playhead} onSelectBone={onSelectBone} onBoneTransform={onBoneTransform} onActorRigReady={onActorRigReady} onLoadStateChange={onLoadStateChange} />;
if (object.kind === "billboard" && object.url) return <DirectorBillboard object={object} selected={selected} />;
const material = <meshStandardMaterial color={selected ? "#2f8cff" : object.color} roughness={0.68} metalness={0.05} />;
return (
@@ -192,19 +530,56 @@ function DirectorObjectVisual({ object, selected, selectedBone, playhead, onSele
);
}
function DirectorModel({ object, selected, selectedBone, playhead, onSelectBone, onBoneTransform, onActorRigReady }: { object: DirectorObject; selected: boolean; selectedBone: string | null; playhead: number; onSelectBone: (bone: string | null) => void; onBoneTransform: (bone: string, rotation: DirectorQuat) => void; onActorRigReady: (rig: DirectorRig, animations: AnimationClip[]) => void }) {
const [model, setModel] = useState<Object3D | null>(null);
const [animations, setAnimations] = useState<AnimationClip[]>([]);
const [rig, setRig] = useState<DirectorRig | null>(null);
const [restRotations, setRestRotations] = useState<Partial<Record<DirectorHumanoidBone, DirectorQuat>>>({});
function DirectorModel({ object, selected, selectedBone, playhead, onSelectBone, onBoneTransform, onActorRigReady, onLoadStateChange }: { object: DirectorObject; selected: boolean; selectedBone: string | null; playhead: number; onSelectBone: (bone: string | null) => void; onBoneTransform: (bone: string, rotation: DirectorQuat) => void; onActorRigReady: (rig: DirectorRig, animations: AnimationClip[]) => void; onLoadStateChange: (id: string, signal: DirectorLoadSignal, retry: () => void) => void }) {
const [load, dispatchLoad] = useReducer(reduceDirectorLoad, directorLoadInitial);
const loadRef = useRef(load);
loadRef.current = load;
const loadGeneration = load.generation;
const modelUrl = object.kind === "actor" || object.primitive === "character" ? DIRECTOR_DEFAULT_ACTOR_URL : object.url;
// 展示身份 = generation + 解析输入。render 阶段用它屏蔽旧资源,
// 因此 prop/retry 变化的第一次 render 就已卸下上一代 model,随后 cleanup 才 dispose。
const identity = directorLoadIdentity({ generation: loadGeneration, url: modelUrl, storageKey: object.storageKey, kind: object.kind });
type LoadedModel = { model: Object3D; rig: DirectorRig; animations: AnimationClip[]; restRotations: Partial<Record<DirectorHumanoidBone, DirectorQuat>> };
const [loaded, setLoaded] = useState<{ identity: string; value: LoadedModel } | null>(null);
const display = resolveDirectorDisplay(loaded, identity);
const model = display?.model ?? null;
const rig = display?.rig ?? null;
const animations = display?.animations ?? emptyAnimations;
const restRotations = display?.restRotations ?? emptyRestRotations;
const setLoadPhase = useCallback((phase: "loading" | "ready" | "error") => {
if (phase === "loading") dispatchLoad({ type: "start" });
else dispatchLoad({ type: phase === "ready" ? "loaded" : "failed", generation: loadRef.current.generation });
}, []);
const mixerRef = useRef<AnimationMixer | null>(null);
const ownedRef = useRef<{ model: Object3D | null; mixer: AnimationMixer | null }>({ model: null, mixer: null });
const onActorRigReadyRef = useRef(onActorRigReady);
const invalidate = useThree((state) => state.invalidate);
// helper 只跟随「当前 identity 下真正要展示的 model」,不会挂在已卸下的旧 model 上。
const helper = useMemo(() => model ? new SkeletonHelper(model) : null, [model]);
useEffect(() => () => disposeDirectorHelper(helper), [helper]);
// 把 loading/error/ready 报到 DOM 层,Canvas 内部无法呈现可操作提示。
// 依赖必须收敛到稳定原始值:object 每次场景编辑都是新引用,
// 若挂在 [object] 上会让下面的注销 effect 每次编辑都误报 unmounted,
// 失败模型的重试入口会随之消失。
const objectId = object.id;
const objectKind = directorDiagnosticObjectKind(object);
const retryLoad = useCallback(() => {
recordDirectorDiagnostic("DIRECTOR_MODEL_LOAD_RETRY", { objectId, objectKind, attempt: loadRef.current.generation, userInitiated: true });
dispatchLoad({ type: "retry" });
}, [objectId, objectKind]);
const onLoadStateChangeRef = useRef(onLoadStateChange);
useEffect(() => { onLoadStateChangeRef.current = onLoadStateChange; }, [onLoadStateChange]);
useEffect(() => {
onLoadStateChangeRef.current(object.id, load.phase, retryLoad);
}, [load.phase, object.id, retryLoad]);
// 卸载(删除、隐藏分支、换 URL/kind、Canvas 重建)必须注销自己,避免通知残留陈旧条目。
// 单独 effect 且只依赖 id:phase 变化不会反复注销重登。
useEffect(() => () => onLoadStateChangeRef.current(object.id, "unmounted", retryLoad), [object.id, retryLoad]);
const selectedBoneObject = selectedBone && rig?.boneMap[selectedBone as DirectorHumanoidBone] ? model?.getObjectByName(rig.boneMap[selectedBone as DirectorHumanoidBone]!) : null;
const motion = object.motionClips?.find((item) => item.id === object.activeMotionClipId);
const activeAnimation = motion ? animations.find((item) => item.name === motion.sourceAnimation) : undefined;
const modelUrl = object.kind === "actor" || object.primitive === "character" ? DIRECTOR_DEFAULT_ACTOR_URL : object.url;
const handleModelPointerDown = useCallback((event: ThreeEvent<PointerEvent>) => {
if (!selected || !rig) return;
let nearestBone: string | null = null;
@@ -228,27 +603,83 @@ function DirectorModel({ object, selected, selectedBone, playhead, onSelectBone,
useEffect(() => { onActorRigReadyRef.current = onActorRigReady; }, [onActorRigReady]);
useEffect(() => {
const generation = loadRef.current.generation;
let active = true;
// render 阶段已由 identity 屏蔽旧资源;这里再清一次作为防御,不作为安全性依据。
setLoaded(null);
setLoadPhase("loading");
const failLoad = () => {
if (!active || generation !== loadRef.current.generation) return;
// 只记录稳定码与安全枚举:URL / storageKey / 原始 error 都不落日志。
recordDirectorDiagnostic("DIRECTOR_MODEL_LOAD_FAILED", { objectId: object.id, objectKind: directorDiagnosticObjectKind(object), attempt: generation });
setLoadPhase("error");
};
const loader = new GLTFLoader();
void resolveMediaUrl(object.storageKey, modelUrl).then((url) => loader.load(url, (gltf) => {
if (!active) return;
const next = SkeletonUtils.clone(gltf.scene);
normalizeModel(next, object.castShadow, object.receiveShadow);
const nextRig = inferDirectorRig(next, gltf.animations.map((clip) => clip.name));
if (object.kind === "actor" || object.primitive === "character") applyActorReferenceMaterial(next, object.color);
mixerRef.current = new AnimationMixer(next);
setRig(nextRig);
setRestRotations(readRigRestRotations(next, nextRig));
setAnimations(gltf.animations);
setModel(next);
onActorRigReadyRef.current(nextRig, gltf.animations);
}, undefined, () => active && setModel(null)));
void resolveMediaUrl(object.storageKey, modelUrl)
.then((url) => {
// 解析完成时已失效:不再发起无意义的网络加载。
if (!active || generation !== loadRef.current.generation) return;
loader.load(
url,
(gltf) => {
const ownership = resolveDirectorLoadOwnership({ active, generation, currentGeneration: loadRef.current.generation });
if (!ownership.adopt) {
// 未被采纳的晚到 source 必须释放,否则孤儿泄漏。
if (ownership.disposeSource) disposeDirectorObject3D(gltf.scene);
return;
}
// 采纳流程整体包裹:clone / normalize / rig 推断 / 材质替换 / mixer /
// setLoaded / onActorRigReady 任一步抛错都不得逃出,也不得留下半采纳资源。
let clone: Object3D | null = null;
let mixer: AnimationMixer | null = null;
try {
// clone 与 source 共享 geometry/material/texture。
// 采纳后 source 的 Object3D 层级可被 GC,共享 GPU 资源由 owned clone 持有,
// 最终只在 clone 的 cleanup 里释放一次;这里绝不 dispose source。
clone = SkeletonUtils.clone(gltf.scene);
normalizeModel(clone, object.castShadow, object.receiveShadow);
const nextRig = inferDirectorRig(
clone,
gltf.animations.map((clip) => clip.name),
);
if (object.kind === "actor" || object.primitive === "character") applyActorReferenceMaterial(clone, object.color);
mixer = new AnimationMixer(clone);
mixerRef.current = mixer;
ownedRef.current = { model: clone, mixer };
// 一次性写入带 identity 的展示记录,避免 model/rig/animations 之间出现中间态。
setLoaded({
identity,
value: { model: clone, rig: nextRig, animations: gltf.animations, restRotations: readRigRestRotations(clone, nextRig) },
});
setLoadPhase("ready");
onActorRigReadyRef.current(nextRig, gltf.animations);
} catch {
// 只记录稳定错误码:原始 error / URL / 素材正文都不落日志。
recordDirectorDiagnostic("DIRECTOR_MODEL_ADOPT_FAILED", { objectId: object.id, objectKind: directorDiagnosticObjectKind(object) });
// 先摘掉 owned 记录,避免 effect cleanup 二次释放同一批资源。
ownedRef.current = { model: null, mixer: null };
mixerRef.current = null;
setLoaded(null);
disposeDirectorAdoptionFailure({ clone, mixer, source: gltf.scene });
// 回到既有 error/retry/占位人偶路径。
failLoad();
}
},
undefined,
failLoad,
);
})
// 地址解析失败与 GLTFLoader onError 走同一条 error/retry 通道。
.catch(failLoad);
return () => {
active = false;
mixerRef.current?.stopAllAction();
// 替换/重试/卸载:释放本 generation 拥有的 clone 与 mixer(共享资源随之恰好释放一次)。
const owned = ownedRef.current;
ownedRef.current = { model: null, mixer: null };
mixerRef.current = null;
disposeDirectorModelResources({ model: owned.model, mixer: owned.mixer });
};
}, [modelUrl, object.kind, object.storageKey]);
}, [identity, modelUrl, object.storageKey]);
useEffect(() => {
if (!model) return;
@@ -303,10 +734,24 @@ function DirectorModel({ object, selected, selectedBone, playhead, onSelectBone,
const selectedFingerGroup = directorFingerGroup(selectedBone);
return <BoneController key={bone} bone={model.getObjectByName(name!)} selected={selectedBone === bone} dimmed={Boolean(fingerGroup && selectedFingerGroup && fingerGroup !== selectedFingerGroup)} onSelect={() => onSelectBone(bone)} />;
}) : null}
{selected && selectedBoneObject ? <TransformControls object={selectedBoneObject} mode="rotate" size={0.55} onMouseUp={() => onBoneTransform(selectedBone!, selectedBoneObject.quaternion.toArray() as DirectorQuat)} /> : null}
{selected && selectedBoneObject && selectedBone ? <DirectorBoneGizmo bone={selectedBoneObject} onCommit={(rotation) => onBoneTransform(selectedBone, rotation)} /> : null}
</group>;
}
/** 骨骼 gizmo 与对象 gizmo 共用事务接线,取消时恢复骨骼 quaternion。 */
function DirectorBoneGizmo({ bone, onCommit }: { bone: Object3D; onCommit: (rotation: DirectorQuat) => void }) {
const transaction = useDirectorGizmoTransaction<DirectorQuat>({
read: () => bone.quaternion.toArray() as DirectorQuat,
restore: (snapshot) => {
bone.quaternion.fromArray(snapshot);
bone.updateMatrixWorld(true);
},
commit: (_from, to) => onCommit(to),
onActive: () => undefined,
});
return <TransformControls object={bone} mode="rotate" size={0.55} onMouseDown={() => transaction.begin()} onMouseUp={() => transaction.end("commit")} />;
}
function DirectorMannequin({ color, selected }: { color: string; selected: boolean }) {
const resolvedColor = selected ? new Color(color).lerp(new Color("#78a9ff"), 0.18).getStyle() : color;
const joints: DirectorVec3[] = [[0, 1.72, 0], [0, 1.48, 0], [-0.3, 1.4, 0], [0.3, 1.4, 0], [-0.32, 1.05, 0], [0.32, 1.05, 0], [-0.33, 0.72, 0], [0.33, 0.72, 0], [-0.13, 0.88, 0], [0.13, 0.88, 0], [-0.13, 0.46, 0], [0.13, 0.46, 0], [-0.13, 0.05, 0], [0.13, 0.05, 0]];
@@ -398,13 +843,17 @@ function normalizeModel(root: Object3D, castShadow: boolean, receiveShadow: bool
function applyActorReferenceMaterial(root: Object3D, color: string) {
const material = new MeshStandardMaterial({ color, roughness: 0.74, metalness: 0.02 });
const replaced: Material[] = [];
root.traverse((child) => {
const mesh = child as Mesh;
if (!mesh.isMesh) return;
// 被顶掉的原材质不再被任何 mesh 引用,必须释放,否则每次加载都泄漏一份。
if (mesh.material) replaced.push(...(Array.isArray(mesh.material) ? mesh.material : [mesh.material]));
mesh.material = material;
mesh.userData.directorActor = true;
mesh.userData.directorActorMaterial = material;
});
disposeDirectorMaterials(replaced.filter((item) => item !== material));
}
function updateActorReferenceColor(root: Object3D, color: string) {
@@ -465,26 +914,52 @@ function applyDirectorBoneTracks(model: Object3D, object: DirectorObject, playhe
const target = name ? model.getObjectByName(name) : null;
if (!target) return;
const humanoidBone = bone as DirectorHumanoidBone;
if (!hasActiveMotion) {
const rest = restRotations[humanoidBone];
if (rest) target.quaternion.copy(new Quaternion(...rest));
const delta = poseDeltas[humanoidBone];
if (delta) target.quaternion.multiply(new Quaternion(...delta));
// hasActiveMotion 时 mixer 已写入 target.quaternion,直接作为动作层输入。
const rotation = resolveDirectorBoneRotation({
motion: hasActiveMotion ? target.quaternion.toArray() as DirectorQuat : null,
rest: hasActiveMotion ? null : restRotations[humanoidBone] || null,
poseDelta: hasActiveMotion ? null : poseDeltas[humanoidBone] || null,
override: object.boneOverrides?.[humanoidBone] || null,
keyframes: object.boneTracks?.find((item) => item.bone === bone)?.keyframes || null,
time: playhead,
});
if (rotation) {
target.quaternion.copy(new Quaternion(...rotation));
return;
}
const override = object.boneOverrides?.[bone as DirectorHumanoidBone];
const track = object.boneTracks?.find((item) => item.bone === bone);
const rotation = track ? interpolateDirectorBoneRotation(override || target.quaternion.toArray() as DirectorQuat, track.keyframes, playhead) : override;
if (rotation) target.quaternion.copy(new Quaternion(...rotation));
if (hasActiveMotion) return;
const rest = restRotations[humanoidBone];
if (rest) target.quaternion.copy(new Quaternion(...rest));
const delta = poseDeltas[humanoidBone];
if (delta) target.quaternion.multiply(new Quaternion(...delta));
});
}
function DirectorBillboard({ object, selected }: { object: DirectorObject; selected: boolean }) {
const [texture, setTexture] = useState<Texture | null>(null);
// 展示状态带 url 身份:只有与当前 object.url 匹配才交给 material,
// 这样换 URL 的第一次 render 就卸下旧纹理,随后 cleanup 才 dispose。
const [loaded, setLoaded] = useState<{ identity: string; value: Texture } | null>(null);
const identity = object.url || "";
const texture = resolveDirectorDisplay(loaded, identity);
useEffect(() => {
let active = true;
new TextureLoader().load(object.url!, (next) => active && setTexture(next), undefined, () => active && setTexture(null));
return () => { active = false; };
}, [object.url]);
let owned: Texture | null = null;
setLoaded(null);
new TextureLoader().load(object.url!, (next) => {
// 晚到的回调:本组件已换 URL 或卸载,直接释放这张纹理。
if (!active) {
next.dispose();
return;
}
owned = next;
setLoaded({ identity, value: next });
}, undefined, () => active && setLoaded(null));
return () => {
active = false;
owned?.dispose();
owned = null;
};
}, [identity, object.url]);
return (
<mesh castShadow={object.castShadow}>
<planeGeometry args={[1.6, 1]} />

View File

@@ -0,0 +1,185 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createDirectorSaveCoordinator, type DirectorCloseDecision, type DirectorDraftEnvelope, type DirectorSaveCoordinator } from "@/lib/canvas/director/director-save";
import { directorSaveProgress, idleDirectorSaveProgress, resolveDirectorDraftStorage, type DirectorSaveProgress } from "@/lib/canvas/director/director-save-wiring";
import { recordDirectorDiagnostic } from "@/lib/canvas/director/director-diagnostics-recorder";
import { useUserStore } from "@/stores/use-user-store";
import type { DirectorScene } from "@/types/director";
export type DirectorSaveController = {
progress: DirectorSaveProgress;
/** canonical 提交入口:预览态与「仅镜像」路径都不得调用。 */
commitScene: (scene: DirectorScene) => void;
retry: () => Promise<boolean>;
restoreCandidate: () => DirectorDraftEnvelope | null;
restoreDraft: (candidate: DirectorDraftEnvelope) => boolean;
discardDraft: () => boolean;
prepareClose: () => Promise<DirectorCloseDecision>;
handlePageHide: () => Promise<boolean>;
};
/**
* 按 scene.id 持有唯一 coordinator:切换场景或卸载时 dispose。
* persistScene 必须把 request.scene 落到项目状态,flushPersistence 负责真正落盘。
*/
export function useDirectorSaveCoordinator(input: { sceneId: string | null; initialScene: DirectorScene | null; persistScene: (scene: DirectorScene) => void; flushPersistence: () => void | Promise<void> }): DirectorSaveController {
const { sceneId, initialScene, persistScene, flushPersistence } = input;
const coordinatorRef = useRef<DirectorSaveCoordinator | null>(null);
const persistSceneRef = useRef(persistScene);
const flushPersistenceRef = useRef(flushPersistence);
const initialSceneRef = useRef(initialScene);
const [progress, setProgress] = useState<DirectorSaveProgress>(idleDirectorSaveProgress);
// scope 取响应式 user id:user-session 先切 active scope 再 setUser,
// 因此这里能在用户变化时重建 coordinator,且不会在播放时每帧读 localStorage。
const scope = useUserStore((state) => state.user?.id) ?? "guest";
// 依赖为空的 effect 每次 render 后都跑,保证 coordinator effect 读到的是最新回调。
useEffect(() => {
persistSceneRef.current = persistScene;
flushPersistenceRef.current = flushPersistence;
initialSceneRef.current = initialScene;
});
useEffect(() => {
const base = initialSceneRef.current;
if (!sceneId || !base || base.id !== sceneId) {
coordinatorRef.current = null;
return;
}
let active = true;
const coordinator = createDirectorSaveCoordinator({
initialScene: base,
scope,
storage: resolveDirectorDraftStorage(() => globalThis.localStorage),
flush: async (request) => {
try {
// 先把这次 flush 对应的场景写进项目状态,再等持久化真正完成。
persistSceneRef.current(request.scene);
await flushPersistenceRef.current();
} catch (error) {
// 只记录稳定码与安全字段;原始 error 继续上抛给 coordinator 走 error/draft 语义。
recordDirectorDiagnostic("DIRECTOR_SAVE_FLUSH_FAILED", { sceneId: request.scene.id, revision: request.revision });
throw error;
}
},
});
coordinatorRef.current = coordinator;
setProgress(directorSaveProgress(coordinator.getSnapshot()));
// 通知里必须读完整快照:只搬 status 会让 confirmedRevision/draftStored 停在旧值,
// UI 继续显示未保存、beforeunload 也会误拦。
const unsubscribe = coordinator.onStatusChange(() => {
if (!active) return;
setProgress(directorSaveProgress(coordinator.getSnapshot()));
});
return () => {
active = false;
unsubscribe();
coordinatorRef.current = null;
// 卸载前把未确认改动推完再 dispose,否则 debounce 里的最后一次编辑只剩本地草稿。
const pending = coordinator.getSnapshot();
if (pending.confirmedRevision < pending.revision) {
void coordinator
.flushLatest()
.catch(() => undefined)
.finally(() => coordinator.dispose());
return;
}
coordinator.dispose();
};
}, [sceneId, scope]);
/** dispose 后 coordinatorRef 为 null,因此卸载期的调用不会再 setState。 */
const syncProgress = useCallback(() => {
const coordinator = coordinatorRef.current;
if (coordinator) setProgress(directorSaveProgress(coordinator.getSnapshot()));
}, []);
/** canonical 提交唯一入口:预览态和「仅镜像当前 draft」都不得调用。 */
const commitScene = useCallback(
(scene: DirectorScene) => {
if (!coordinatorRef.current?.edit(scene)) return;
syncProgress();
},
[syncProgress],
);
const retry = useCallback(async () => {
const coordinator = coordinatorRef.current;
if (!coordinator) return false;
const result = await coordinator.retry();
syncProgress();
return result;
}, [syncProgress]);
const restoreCandidate = useCallback(() => coordinatorRef.current?.restoreCandidate() ?? null, []);
const restoreDraft = useCallback(
(candidate: DirectorDraftEnvelope) => {
const coordinator = coordinatorRef.current;
if (!coordinator?.explicitRestore(candidate)) return false;
// 恢复后必须回到项目状态并触发可靠保存,否则草稿只活在本地。
persistSceneRef.current(candidate.scene);
void coordinator.flushLatest().finally(syncProgress);
syncProgress();
return true;
},
[syncProgress],
);
const discardDraft = useCallback(() => {
const discarded = coordinatorRef.current?.discardDraft() ?? false;
syncProgress();
return discarded;
}, [syncProgress]);
const prepareClose = useCallback(async () => {
const coordinator = coordinatorRef.current;
if (!coordinator) return "close" as DirectorCloseDecision;
const decision = await coordinator.prepareClose();
syncProgress();
return decision;
}, [syncProgress]);
/**
* pagehide:dirty 时只能走 coordinator.handlePageHide —— 它的 flush 已经
* persist(request.scene) + await flushPersistence,组件再叠一次就是重复落盘。
* clean 时 coordinator 不会 flush,因此这里显式落盘,保留原有「离开页面必落盘」语义。
*/
const handlePageHide = useCallback(async () => {
const coordinator = coordinatorRef.current;
try {
if (!coordinator) {
await flushPersistenceRef.current();
return false;
}
const pending = coordinator.getSnapshot();
if (pending.confirmedRevision === pending.revision) {
await flushPersistenceRef.current();
return true;
}
return await coordinator.handlePageHide();
} catch {
return false;
} finally {
syncProgress();
}
}, [syncProgress]);
return {
progress,
commitScene,
retry,
restoreCandidate,
restoreDraft,
discardDraft,
prepareClose,
handlePageHide,
};
}

View File

@@ -48,13 +48,22 @@ export function AppProviders({ children }: { children: ReactNode }) {
document.documentElement.style.colorScheme = theme;
}, [dark, theme]);
// DEV 复现台必须是同源本地确定性场景:AuthSessionHydrator 会打 /api/auth/session,
// ClientRootInit 会打 /api/model-catalog,没有后端时产生真实 502,与导演台无关却会污染判据。
// 只精确匹配该路径;生产构建中 import.meta.env.DEV 为 false,本分支被摇树删除。
const isolateDevRepro = import.meta.env.DEV && typeof window !== "undefined" && window.location.pathname === "/dev/director-repro";
return (
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}>
<App message={{ duration: 3, maxCount: 3 }} notification={{ duration: 4.5, maxCount: 3, placement: "topRight" }}>
<QueryClientProvider client={appQueryClient}>
<AuthSessionHydrator>
<ClientRootInit>{children}</ClientRootInit>
</AuthSessionHydrator>
{isolateDevRepro ? (
children
) : (
<AuthSessionHydrator>
<ClientRootInit>{children}</ClientRootInit>
</AuthSessionHydrator>
)}
</QueryClientProvider>
</App>
</ConfigProvider>