* fix(canvas): 3D 导演台 - 修复状态回弹与保存恢复生命周期 * style(canvas): 3D 导演台 - 对齐 CI 格式门禁
This commit is contained in:
1 parent
ad9c74ba64
commit
ad0775605d
42 files changed
+8124
-188
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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]} />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -1784,7 +1784,7 @@ function InfiniteCanvasPage() {
|
||||
<CanvasDirectorNodePanel
|
||||
node={contentNode}
|
||||
scene={currentProject?.directorScenes?.find((scene) => scene.id === contentNode.metadata?.directorSceneId) || null}
|
||||
previewUrl={nodesRef.current.find((item) => item.id === contentNode.metadata?.directorPreviewNodeId)?.metadata?.content}
|
||||
readNodeContent={(nodeId) => (nodeId ? nodesRef.current.find((item) => item.id === nodeId)?.metadata?.content : undefined)}
|
||||
professional={workspaceMode === "professional"}
|
||||
onOpen={() => openDirectorWorkbench(contentNode.id)}
|
||||
/>
|
||||
@@ -2598,6 +2598,7 @@ function InfiniteCanvasPage() {
|
||||
onChange={saveDirectorScene}
|
||||
onApply={applyDirectorOutput}
|
||||
onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))}
|
||||
onFlush={() => flushCanvasStorePersistence()}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-tas
|
||||
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { createDirectorScene } from "@/lib/canvas/director/director-scene";
|
||||
import { upsertDirectorSceneById } from "@/lib/canvas/director/director-session";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
|
||||
import type { DirectorScene, DirectorSceneOutput } from "@/types/director";
|
||||
|
||||
@@ -28,6 +30,14 @@ type UseCanvasDirectorOptions = {
|
||||
|
||||
const NODE_STATUS_IDLE = "idle" as const;
|
||||
|
||||
/**
|
||||
* 项目真实内存权威在 useCanvasStore.getState().projects;
|
||||
* 闭包里的 directorScenes 只作为 store 尚未就绪时的兜底。
|
||||
*/
|
||||
function currentDirectorScenes(projectId: string, fallback: DirectorScene[]) {
|
||||
const project = useCanvasStore.getState().projects.find((item) => item.id === projectId);
|
||||
return project?.directorScenes ?? fallback;
|
||||
}
|
||||
export function useCanvasDirector({
|
||||
projectId,
|
||||
directorNodeId,
|
||||
@@ -68,14 +78,14 @@ export function useCanvasDirector({
|
||||
setNodes(nextNodes);
|
||||
setSelectedNodeIds(new Set([node.id]));
|
||||
setSelectedConnectionId(null);
|
||||
updateProject(projectId, { directorScenes: [...directorScenes, scene] });
|
||||
updateProject(projectId, { directorScenes: upsertDirectorSceneById(currentDirectorScenes(projectId, directorScenes), scene) });
|
||||
message.success("已创建导演台节点,点击缩略图进入编辑");
|
||||
}, [directorScenes, getCanvasCenter, message, nodesRef, projectId, setNodes, setSelectedConnectionId, setSelectedNodeIds, updateProject]);
|
||||
|
||||
const openDirectorWorkbench = useCallback((nodeId: string) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
if (!node || node.metadata?.workflowKind !== "shot") return;
|
||||
let scene = directorScenes.find((item) => item.id === node.metadata?.directorSceneId);
|
||||
let scene = currentDirectorScenes(projectId, directorScenes).find((item) => item.id === node.metadata?.directorSceneId);
|
||||
if (!scene) {
|
||||
scene = createDirectorScene(node.metadata?.workflowTitle || node.title || "镜头场景");
|
||||
const shot = scene.shots[0];
|
||||
@@ -83,13 +93,14 @@ export function useCanvasDirector({
|
||||
const directorSceneId = scene.id;
|
||||
const directorShotId = shot.id;
|
||||
setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, metadata: { ...item.metadata, directorSceneId, directorShotId } } : item));
|
||||
updateProject(projectId, { directorScenes: [...directorScenes, scene] });
|
||||
updateProject(projectId, { directorScenes: upsertDirectorSceneById(currentDirectorScenes(projectId, directorScenes), scene) });
|
||||
}
|
||||
setDirectorNodeId(nodeId);
|
||||
}, [directorScenes, nodesRef, projectId, setDirectorNodeId, setNodes, updateProject]);
|
||||
|
||||
/** 每次保存都基于 store 中最新 directorScenes upsert,避免旧闭包数组覆盖并发保存。 */
|
||||
const saveDirectorScene = useCallback((scene: DirectorScene) => {
|
||||
updateProject(projectId, { directorScenes: directorScenes.some((item) => item.id === scene.id) ? directorScenes.map((item) => item.id === scene.id ? scene : item) : [...directorScenes, scene] });
|
||||
updateProject(projectId, { directorScenes: upsertDirectorSceneById(currentDirectorScenes(projectId, directorScenes), scene) });
|
||||
}, [directorScenes, projectId, updateProject]);
|
||||
|
||||
const applyDirectorOutput = useCallback(async (output: DirectorSceneOutput) => {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Button, Switch, Table, Tag } from "antd";
|
||||
|
||||
import { CanvasDirectorWorkbench } from "@/components/canvas/director/canvas-director-workbench";
|
||||
import { DIRECTOR_REPRO_MATRIX, createDirectorReproScene, directorReproSceneIsOffline, injectDirectorReproModel, type DirectorReproModelVariant } from "@/lib/canvas/director/director-repro-fixture";
|
||||
import { readDirectorReproSnapshot, type DirectorReproSnapshot } from "@/lib/canvas/director/director-repro-runtime";
|
||||
import { resetDirectorDiagnosticDedupe } from "@/lib/canvas/director/director-diagnostics-recorder";
|
||||
import { getClientDiagnosticEvents } from "@/services/diagnostics/client-diagnostics";
|
||||
import type { DirectorScene } from "@/types/director";
|
||||
|
||||
/**
|
||||
* P0 手工复现入口(仅 DEV 注册)。
|
||||
*
|
||||
* 渲染真实 CanvasDirectorWorkbench + 确定性 fixture,不是静态 mock。
|
||||
* 本页自己的 callback 都是本地确定性实现,不调用任何 API:
|
||||
* onChange 只写本页 state,onFlush 只在「强制失败」开关打开时抛错,
|
||||
* onApply 只累加本地计数。
|
||||
*
|
||||
* 范围声明:只有「初始 fixture + 本页 callback」保证不发网。
|
||||
* 真实工作台自带的新增控件(默认演员 GLB、上传模型、画布图片立牌)
|
||||
* 仍可能联网,这是刻意保留的真实行为 —— 不要据此宣称整个工作台永不发网。
|
||||
*/
|
||||
export default function DirectorReproLab() {
|
||||
const [scene, setScene] = useState<DirectorScene>(() => createDirectorReproScene());
|
||||
// workbench 是 fixed inset-0 全屏浮层,会完全遮住本页。默认关闭,
|
||||
// 让环境快照 / 事件列表 / 复现矩阵先可读,再由用户显式打开进入复现。
|
||||
const [workbenchOpen, setWorkbenchOpen] = useState(false);
|
||||
const [forceSaveFailure, setForceSaveFailure] = useState(false);
|
||||
const [appliedCount, setAppliedCount] = useState(0);
|
||||
const [flushCount, setFlushCount] = useState(0);
|
||||
const [events, setEvents] = useState(() => readDirectorEvents());
|
||||
const snapshot = useMemo(() => readDirectorReproSnapshot(), []);
|
||||
|
||||
const refreshEvents = useCallback(() => setEvents(readDirectorEvents()), []);
|
||||
|
||||
/** 本地确定性保存:只写本页 state,不触碰项目 store,也不发请求。 */
|
||||
const onChange = useCallback((next: DirectorScene) => setScene(next), []);
|
||||
|
||||
/** 强制失败开关用于复现「保存失败 / 关闭恢复」,抛错后由 coordinator 走真实 error 语义。 */
|
||||
const onFlush = useCallback(() => {
|
||||
setFlushCount((count) => count + 1);
|
||||
if (forceSaveFailure) throw new Error("repro forced flush failure");
|
||||
}, [forceSaveFailure]);
|
||||
|
||||
const onApply = useCallback(async () => {
|
||||
setAppliedCount((count) => count + 1);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
resetDirectorDiagnosticDedupe();
|
||||
setScene(createDirectorReproScene());
|
||||
setForceSaveFailure(false);
|
||||
setAppliedCount(0);
|
||||
setFlushCount(0);
|
||||
setWorkbenchOpen(false);
|
||||
refreshEvents();
|
||||
}, [refreshEvents]);
|
||||
|
||||
/** 显式注入模型对象:固定 id,重复点击替换同一对象而不是堆叠。 */
|
||||
const injectModel = useCallback((variant: DirectorReproModelVariant) => {
|
||||
setScene((current) => injectDirectorReproModel(current, variant));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh overflow-y-auto p-5" style={{ background: "var(--bg)", color: "var(--fg)" }}>
|
||||
<header className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">导演台 P0 复现台</h1>
|
||||
<Tag color={directorReproSceneIsOffline(scene) ? "green" : "red"} data-testid="offline-tag">
|
||||
{directorReproSceneIsOffline(scene) ? "fixture 无网络资产" : "已注入模型资产"}
|
||||
</Tag>
|
||||
<span className="text-[var(--fs-tiny)] opacity-70" data-testid="object-count">
|
||||
对象数 {scene.objects.length}
|
||||
</span>
|
||||
<span className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<Button size="small" data-testid="inject-local-model" onClick={() => injectModel("local")}>
|
||||
注入本地模型
|
||||
</Button>
|
||||
<Button size="small" data-testid="inject-missing-model" onClick={() => injectModel("missing")}>
|
||||
注入缺失模型
|
||||
</Button>
|
||||
<span className="text-[var(--fs-tiny)] opacity-70">强制保存失败</span>
|
||||
<Switch checked={forceSaveFailure} onChange={setForceSaveFailure} data-testid="force-save-failure" />
|
||||
<Button size="small" data-testid="toggle-workbench" onClick={() => setWorkbenchOpen((open) => !open)}>
|
||||
{workbenchOpen ? "关闭导演台" : "打开导演台"}
|
||||
</Button>
|
||||
<Button size="small" data-testid="refresh-events" onClick={refreshEvents}>
|
||||
刷新事件
|
||||
</Button>
|
||||
<Button size="small" data-testid="reset-lab" onClick={reset}>
|
||||
重置
|
||||
</Button>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<EnvironmentSnapshot snapshot={snapshot} appliedCount={appliedCount} flushCount={flushCount} sceneRevisionHint={scene.updatedAt} />
|
||||
<DiagnosticEventList events={events} />
|
||||
<ReproMatrix />
|
||||
|
||||
{workbenchOpen ? <CanvasDirectorWorkbench open scene={scene} imageNodes={[]} onClose={() => setWorkbenchOpen(false)} onChange={onChange} onApply={onApply} onDeleteImageNode={() => undefined} onFlush={onFlush} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DirectorEventRow = { id: string; timestamp: string; level: string; code: string; message: string };
|
||||
|
||||
/** 只显示导演台自己的稳定码事件,避免整条诊断流噪声淹没复现现场。 */
|
||||
function readDirectorEvents(): DirectorEventRow[] {
|
||||
return getClientDiagnosticEvents()
|
||||
.filter((event) => (event.code || "").startsWith("DIRECTOR_"))
|
||||
.map((event) => ({ id: event.id, timestamp: event.timestamp, level: event.level, code: event.code || "", message: event.message }))
|
||||
.reverse();
|
||||
}
|
||||
|
||||
function EnvironmentSnapshot({ snapshot, appliedCount, flushCount, sceneRevisionHint }: { snapshot: DirectorReproSnapshot; appliedCount: number; flushCount: number; sceneRevisionHint: string }) {
|
||||
const { runtime, webgl } = snapshot;
|
||||
const rows: Array<[string, string]> = [
|
||||
["应用版本", runtime.appVersion],
|
||||
["构建提交", runtime.buildCommit],
|
||||
["浏览器", runtime.browser || "(不可用)"],
|
||||
["操作系统", runtime.os || "(不可用)"],
|
||||
["时区", runtime.timezone || "(不可用)"],
|
||||
["DPR", String(runtime.devicePixelRatio)],
|
||||
["本地 onApply 次数", String(appliedCount)],
|
||||
["本地 onFlush 次数", String(flushCount)],
|
||||
["场景 updatedAt", sceneRevisionHint],
|
||||
];
|
||||
|
||||
if (webgl.available) {
|
||||
rows.push(
|
||||
["WebGL 版本", webgl.version || "(空)"],
|
||||
["WebGL vendor", webgl.vendor || "(空)"],
|
||||
["WebGL renderer", webgl.renderer || "(空)"],
|
||||
["最大纹理尺寸", String(webgl.maxTextureSize)],
|
||||
["最大 renderbuffer", String(webgl.maxRenderbufferSize)],
|
||||
["最大视口", webgl.maxViewportWidth + "×" + webgl.maxViewportHeight],
|
||||
);
|
||||
} else {
|
||||
rows.push(["WebGL", webgl.reason === "unsupported" ? "不支持(已稳定降级)" : "上下文创建失败(已稳定降级)"]);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h2 className="mb-2 text-[var(--fs-label)] font-semibold opacity-75">环境快照</h2>
|
||||
<p className="mb-2 text-[var(--fs-tiny)] opacity-60">仅本地展示,不上报;字符串已限长并剥离 URL query/hash 与凭证片段。</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 md:grid-cols-3">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="flex min-w-0 gap-2 text-[var(--fs-tiny)]">
|
||||
<span className="shrink-0 opacity-55">{label}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DiagnosticEventList({ events }: { events: DirectorEventRow[] }) {
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h2 className="mb-2 text-[var(--fs-label)] font-semibold opacity-75">结构化事件({events.length})</h2>
|
||||
<p className="mb-2 text-[var(--fs-tiny)] opacity-60">来自统一 client-diagnostics 缓冲区,只筛选 DIRECTOR_ 稳定码;不含 stack、URL 与业务正文。</p>
|
||||
{events.length ? (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
dataSource={events}
|
||||
pagination={false}
|
||||
scroll={{ y: 220 }}
|
||||
columns={[
|
||||
{ title: "时间", dataIndex: "timestamp", width: 200, render: (value: string) => <span className="font-mono text-[var(--fs-tiny)]">{value}</span> },
|
||||
{ title: "级别", dataIndex: "level", width: 88, render: (value: string) => <Tag color={value === "error" ? "red" : value === "warning" ? "orange" : "blue"}>{value}</Tag> },
|
||||
{ title: "稳定码", dataIndex: "code", render: (value: string) => <span className="font-mono text-[var(--fs-tiny)]">{value}</span> },
|
||||
{ title: "消息", dataIndex: "message" },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[var(--fs-tiny)] opacity-55">暂无导演台事件。触发下方矩阵中的失败场景后点击「刷新事件」。</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReproMatrix() {
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h2 className="mb-2 text-[var(--fs-label)] font-semibold opacity-75">P0 手工复现矩阵({DIRECTOR_REPRO_MATRIX.length})</h2>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
dataSource={[...DIRECTOR_REPRO_MATRIX]}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "分组", dataIndex: "group", width: 108 },
|
||||
{ title: "场景", dataIndex: "title", width: 190 },
|
||||
{ title: "操作", dataIndex: "steps" },
|
||||
{ title: "预期", dataIndex: "expected" },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+18
-4
@@ -46,7 +46,6 @@ const ProjectsPage = lazy(loadProjectsPage);
|
||||
const ProjectDetailPage = lazy(() => import("@/pages/projects/detail"));
|
||||
const SettingsPage = lazy(() => import("@/pages/settings"));
|
||||
const TestVoiceRecording = lazy(() => import("@/pages/test-voice-recording"));
|
||||
const FolderPreviewLab = lazy(() => import("@/pages/dev/folder-preview-lab"));
|
||||
|
||||
function deferred(element: ReactNode) {
|
||||
return <Suspense fallback={<WorkspaceRouteLoader />}>{element}</Suspense>;
|
||||
@@ -56,6 +55,23 @@ function fullScreenDeferred(element: ReactNode) {
|
||||
return <Suspense fallback={<FullScreenLoader label="正在打开创作空间" detail="准备当前页面" />}>{element}</Suspense>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV 专用实验室路由。
|
||||
*
|
||||
* lazy(() => import(...)) 写在函数体内,而不是模块顶层常量:
|
||||
* 生产构建时 import.meta.env.DEV 被替换为 false,本函数随之不可达,
|
||||
* 摇树会连同其中的动态 import 一起删除,实验室代码不进入生产依赖图。
|
||||
* 若把 lazy 提到模块顶层,动态 import 会被静态分析成真实 chunk 并打进 dist。
|
||||
*/
|
||||
function devRoutes() {
|
||||
const FolderPreviewLab = lazy(() => import("@/pages/dev/folder-preview-lab"));
|
||||
const DirectorReproLab = lazy(() => import("@/pages/dev/director-repro-lab"));
|
||||
return [
|
||||
{ path: "/dev/folders", element: fullScreenDeferred(<FolderPreviewLab />), errorElement: <RouteErrorPage /> },
|
||||
{ path: "/dev/director-repro", element: fullScreenDeferred(<DirectorReproLab />), errorElement: <RouteErrorPage /> },
|
||||
];
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
element: <AuthScene />,
|
||||
@@ -66,9 +82,7 @@ export const router = createBrowserRouter([
|
||||
],
|
||||
},
|
||||
{ path: "/share/canvas/:token", element: fullScreenDeferred(<SharedCanvasPage />), errorElement: <RouteErrorPage /> },
|
||||
...(import.meta.env.DEV
|
||||
? [{ path: "/dev/folders", element: fullScreenDeferred(<FolderPreviewLab />), errorElement: <RouteErrorPage /> }]
|
||||
: []),
|
||||
...(import.meta.env.DEV ? devRoutes() : []),
|
||||
{
|
||||
element: (
|
||||
<UserLayout>
|
||||
|
||||
@@ -26,7 +26,7 @@ type DirectorWorkbenchStore = {
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
const initialState = { selectedObjectId: null, selectedBone: null, selectedLightId: null, transformMode: "translate" as const, renderMode: "beauty" as const, playhead: 0, playing: false, autoKey: true, sequencerHeight: 300, sequencerVisible: true };
|
||||
const initialState = { selectedObjectId: null, selectedBone: null, selectedLightId: null, transformMode: "translate" as const, renderMode: "beauty" as const, playhead: 0, playing: false, autoKey: false, sequencerHeight: 300, sequencerVisible: true };
|
||||
|
||||
export const useDirectorWorkbenchStore = create<DirectorWorkbenchStore>((set) => ({
|
||||
...initialState,
|
||||
|
||||
@@ -9604,6 +9604,68 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.director-viewport-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.director-viewport-notice {
|
||||
position: absolute;
|
||||
z-index: var(--z-toolbar);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
border: var(--stroke-1) solid var(--director-sequencer-border);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--director-dock-surface);
|
||||
box-shadow: var(--shadow-xl);
|
||||
color: var(--director-dock-fg-strong);
|
||||
}
|
||||
|
||||
.director-viewport-notice.is-cover {
|
||||
inset: 50% auto auto 50%;
|
||||
max-width: 320px;
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.director-viewport-notice.is-corner {
|
||||
top: var(--space-3);
|
||||
right: var(--space-3);
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.director-viewport-notice-title {
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.director-viewport-notice-text {
|
||||
margin: 0;
|
||||
color: var(--director-dock-fg);
|
||||
font-size: var(--fs-tiny);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.director-viewport-notice-action {
|
||||
height: var(--space-8);
|
||||
padding: 0 var(--space-3);
|
||||
justify-self: start;
|
||||
border: var(--stroke-1) solid var(--director-sequencer-border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--director-dock-active-surface);
|
||||
color: var(--director-dock-fg-strong);
|
||||
font-size: var(--fs-tiny);
|
||||
transition: background var(--motion-dur-fast) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
.director-viewport-notice-action:hover {
|
||||
background: var(--director-control-hover);
|
||||
}
|
||||
|
||||
.director-viewport-dock {
|
||||
position: absolute;
|
||||
z-index: var(--z-toolbar);
|
||||
@@ -9733,6 +9795,7 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.director-viewport-dock-button,
|
||||
.director-viewport-notice-action,
|
||||
.director-pose-button {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user