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

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

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

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

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

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

View File

@@ -47,6 +47,7 @@ import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dial
import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal";
import { CanvasNodeSearchModal } from "@/components/canvas/canvas-node-search-modal";
import { CanvasStylePickerModal } from "@/components/canvas/canvas-style-picker-modal";
import { CanvasDirectorTemplateModal } from "@/components/canvas/director/canvas-director-template-modal";
import { CanvasFileDropOverlay } from "@/components/canvas/canvas-file-drop-overlay";
import { CanvasUploadModal } from "@/components/canvas/canvas-upload-modal";
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
@@ -190,6 +191,7 @@ function InfiniteCanvasPage() {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || "");
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
const [connections, setConnections] = useState<CanvasConnection[]>([]);
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
@@ -219,6 +221,8 @@ function InfiniteCanvasPage() {
const [characterReferenceNodeId, setCharacterReferenceNodeId] = useState<string | null>(null);
const [drawingNodeId, setDrawingNodeId] = useState<string | null>(null);
const [stylePickerOpen, setStylePickerOpen] = useState(false);
// 新建导演台镜头必须先选模板:null 表示未在选择中,undefined position 表示用画布中心。
const [directorTemplateRequest, setDirectorTemplateRequest] = useState<{ position?: Position } | null>(null);
const [projectAssetOpen, setProjectAssetOpen] = useState(false);
const [projectAssetInitialCategory, setProjectAssetInitialCategory] = useState("all");
const [projectAssetInitialFolderId, setProjectAssetInitialFolderId] = useState("all");
@@ -2018,6 +2022,12 @@ function InfiniteCanvasPage() {
<CanvasStylePickerModal open={stylePickerOpen} value={activeStylePresetId} applying={styleApplying} onClose={() => setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
<CanvasDirectorTemplateModal
open={Boolean(directorTemplateRequest)}
onClose={() => setDirectorTemplateRequest(null)}
onSelect={(templateId) => createDirectorShot(templateId, directorTemplateRequest?.position)}
/>
<div className="relative flex min-h-0 min-w-0 flex-1">
<div className="relative min-w-0 flex-1 overflow-hidden">
<InfiniteCanvas
@@ -2175,7 +2185,7 @@ function InfiniteCanvasPage() {
onAddDrawing={() => createNode(CanvasNodeType.Drawing)}
onAddExtensionNode={(type) => createNode(type)}
onAddWorkflow={() => createNode(CanvasNodeType.Config)}
onOpenDirector={() => createDirectorShot()}
onOpenDirector={() => setDirectorTemplateRequest({})}
onUndo={undoCanvas}
onRedo={redoCanvas}
onUpload={() => handleUploadRequest()}
@@ -2404,7 +2414,7 @@ function InfiniteCanvasPage() {
onAddNode={(type, position) => createNode(type, position)}
onAddFolder={createFolder}
onChooseStyle={() => setStylePickerOpen(true)}
onOpenDirector={createDirectorShot}
onOpenDirector={(position) => setDirectorTemplateRequest({ position })}
onUpload={(nodeId, position) => handleUploadRequest(nodeId, position)}
onOpenAssets={openCanvasAssetLibrary}
onOpenProjectCharacters={(position) => openProjectAssets("character", position)}
@@ -2599,6 +2609,7 @@ function InfiniteCanvasPage() {
onApply={applyDirectorOutput}
onDeleteImageNode={(nodeId) => deleteNodes(new Set([nodeId]))}
onFlush={() => flushCanvasStorePersistence()}
onboardingScope={directorOnboardingScope}
/>
</Suspense>
) : null}

View File

@@ -1,12 +1,12 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import { nanoid } from "nanoid";
import { imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
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 { createDirectorSceneFromTemplate, type DirectorTemplateId } from "@/lib/canvas/director/director-templates";
import { mergeDirectorOutputPreview, 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";
@@ -53,11 +53,24 @@ export function useCanvasDirector({
updateProject,
}: UseCanvasDirectorOptions) {
const { message } = App.useApp();
const projectIdRef = useRef<string | null>(projectId);
projectIdRef.current = projectId;
const createDirectorShot = useCallback((position?: Position) => {
useEffect(() => {
projectIdRef.current = projectId;
return () => {
if (projectIdRef.current === projectId) projectIdRef.current = null;
};
}, [projectId]);
/**
* 新建镜头。templateId 由调用方(模板选择弹窗)显式给出 —— 这里不设默认模板,
* 否则又会回到「无条件塞一个默认演员」的老问题。
*/
const createDirectorShot = useCallback((templateId: DirectorTemplateId, position?: Position) => {
const shots = nodesRef.current.filter((node) => node.metadata?.workflowKind === "shot");
const shotIndex = Math.max(0, ...shots.map((node) => node.metadata?.shotIndex || 0)) + 1;
let scene = createDirectorScene(`镜头 ${shotIndex}`);
let scene = createDirectorSceneFromTemplate(templateId, `镜头 ${shotIndex}`);
const shot = scene.shots[0];
scene = { ...scene, shots: [{ ...shot, name: `镜头 ${shotIndex}` }] };
const node = createCanvasNode(CanvasNodeType.Video, position || getCanvasCenter(), {
@@ -87,7 +100,9 @@ export function useCanvasDirector({
if (!node || node.metadata?.workflowKind !== "shot") return;
let scene = currentDirectorScenes(projectId, directorScenes).find((item) => item.id === node.metadata?.directorSceneId);
if (!scene) {
scene = createDirectorScene(node.metadata?.workflowTitle || node.title || "镜头场景");
// 孤儿节点修复路径:节点存在但场景丢了。这不是「新建」,不弹模板选择,
// 用空场景兜底 —— 绝不在用户没选过的情况下塞演员进去。
scene = createDirectorSceneFromTemplate("empty", node.metadata?.workflowTitle || node.title || "镜头场景");
const shot = scene.shots[0];
scene = { ...scene, shots: [{ ...shot, name: node.metadata?.workflowTitle || node.title || shot.name, prompt: node.metadata?.workflowDescription || "" }] };
const directorSceneId = scene.id;
@@ -104,13 +119,24 @@ export function useCanvasDirector({
}, [directorScenes, projectId, updateProject]);
const applyDirectorOutput = useCallback(async (output: DirectorSceneOutput) => {
const sourceNode = nodesRef.current.find((item) => item.id === directorNodeId);
if (!sourceNode) throw new Error("镜头节点不存在");
const outputProjectId = projectId;
if (projectIdRef.current !== outputProjectId) throw new Error("画布项目已切换,请重试");
const sourceNodeAtStart = nodesRef.current.find((item) => item.id === directorNodeId);
if (!sourceNodeAtStart || sourceNodeAtStart.metadata?.directorSceneId !== output.scene.id) throw new Error("镜头节点不存在或场景已切换");
const sourceNodeId = sourceNodeAtStart.id;
const [image, videoUpload] = await Promise.all([
uploadImage(output.beauty),
output.clayVideo ? uploadMediaFile(output.clayVideo, "director-clay") : Promise.resolve(null),
]);
// 上传期间项目、节点和镜头都可能变化。以当前权威状态重新核验并合并,
// 不允许旧输出写入另一项目,也不允许旧 scene 快照覆盖并发编辑。
const outputProject = useCanvasStore.getState().projects.find((item) => item.id === outputProjectId);
const sourceNode = nodesRef.current.find((item) => item.id === sourceNodeId);
const latestScene = outputProject?.directorScenes.find((item) => item.id === output.scene.id);
if (projectIdRef.current !== outputProjectId || !outputProject || !sourceNode || sourceNode.metadata?.directorSceneId !== output.scene.id || !latestScene || !latestScene.shots.some((shot) => shot.id === output.shot.id)) throw new Error("输出期间项目或镜头已切换、删除,请重试");
const previewId = sourceNode.metadata?.directorPreviewNodeId || `image-director-${Date.now()}`;
const mergedScene = mergeDirectorOutputPreview(latestScene, { sceneId: output.scene.id, shotId: output.shot.id, previewNodeId: previewId });
if (!mergedScene) throw new Error("输出期间镜头已切换或删除,请重试");
const previewSize = fitNodeSize(image.width, image.height);
const nextNodes = [...nodesRef.current];
const previewIndex = nextNodes.findIndex((item) => item.id === previewId);
@@ -175,8 +201,8 @@ export function useCanvasDirector({
connectionsRef.current = nextConnections;
setNodes(finalizedNodes);
setConnections(nextConnections);
saveDirectorScene({ ...output.scene, shots: output.scene.shots.map((shot) => shot.id === output.shot.id ? { ...shot, previewNodeId: previewId, depthNodeId: undefined, normalNodeId: undefined } : shot) });
}, [connectionsRef, directorNodeId, nodesRef, saveDirectorScene, setConnections, setNodes]);
saveDirectorScene(mergedScene);
}, [connectionsRef, directorNodeId, nodesRef, projectId, saveDirectorScene, setConnections, setNodes]);
return { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene };
}

View File

@@ -96,7 +96,9 @@ export default function DirectorReproLab() {
<DiagnosticEventList events={events} />
<ReproMatrix />
{workbenchOpen ? <CanvasDirectorWorkbench open scene={scene} imageNodes={[]} onClose={() => setWorkbenchOpen(false)} onChange={onChange} onApply={onApply} onDeleteImageNode={() => undefined} onFlush={onFlush} /> : null}
{workbenchOpen ? (
<CanvasDirectorWorkbench open scene={scene} imageNodes={[]} onboardingScope="director-repro-lab" onClose={() => setWorkbenchOpen(false)} onChange={onChange} onApply={onApply} onDeleteImageNode={() => undefined} onFlush={onFlush} />
) : null}
</div>
);
}