Files
ttstd-infinite-canvas/web/src/pages/canvas/use-canvas-director.ts
echoD886 0e33cc805e feat(canvas): 3D 导演台 - 补齐非 P0 分层交互与回归保护 (#333)
* feat(canvas): 3D 导演台 - 补齐分层交互与回归保护

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

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

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

* test(canvas): 3D 导演台 - 稳定真实点击验证
2026-08-27 20:47:10 +08:00

209 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
import type { DirectorScene, DirectorSceneOutput } from "@/types/director";
type UseCanvasDirectorOptions = {
projectId: string;
directorNodeId: string | null;
directorScenes: DirectorScene[];
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
getCanvasCenter: () => Position;
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setDirectorNodeId: Dispatch<SetStateAction<string | null>>;
updateProject: (projectId: string, patch: { directorScenes: DirectorScene[] }) => void;
};
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,
directorScenes,
nodesRef,
connectionsRef,
getCanvasCenter,
setNodes,
setConnections,
setSelectedNodeIds,
setSelectedConnectionId,
setDirectorNodeId,
updateProject,
}: UseCanvasDirectorOptions) {
const { message } = App.useApp();
const projectIdRef = useRef<string | null>(projectId);
projectIdRef.current = projectId;
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 = createDirectorSceneFromTemplate(templateId, `镜头 ${shotIndex}`);
const shot = scene.shots[0];
scene = { ...scene, shots: [{ ...shot, name: `镜头 ${shotIndex}` }] };
const node = createCanvasNode(CanvasNodeType.Video, position || getCanvasCenter(), {
workflowKind: "shot",
workflowTitle: `镜头 ${shotIndex}`,
shotIndex,
generationMode: "video",
videoEditOperation: "text_to_video",
status: NODE_STATUS_IDLE,
composerContent: "",
directorSceneId: scene.id,
directorShotId: shot.id,
});
node.title = `镜头 ${shotIndex}`;
node.height = 300;
const nextNodes = [...nodesRef.current, node];
nodesRef.current = nextNodes;
setNodes(nextNodes);
setSelectedNodeIds(new Set([node.id]));
setSelectedConnectionId(null);
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 = currentDirectorScenes(projectId, directorScenes).find((item) => item.id === node.metadata?.directorSceneId);
if (!scene) {
// 孤儿节点修复路径:节点存在但场景丢了。这不是「新建」,不弹模板选择,
// 用空场景兜底 —— 绝不在用户没选过的情况下塞演员进去。
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;
const directorShotId = shot.id;
setNodes((current) => current.map((item) => item.id === nodeId ? { ...item, metadata: { ...item.metadata, directorSceneId, directorShotId } } : item));
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: upsertDirectorSceneById(currentDirectorScenes(projectId, directorScenes), scene) });
}, [directorScenes, projectId, updateProject]);
const applyDirectorOutput = useCallback(async (output: DirectorSceneOutput) => {
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);
const existingPreview = previewIndex >= 0 ? nextNodes[previewIndex] : null;
const previewNode: CanvasNodeData = {
...existingPreview,
id: previewId,
type: CanvasNodeType.Image,
title: `${sourceNode.title} · 导演台构图`,
position: existingPreview?.position || { x: sourceNode.position.x - previewSize.width - 36, y: sourceNode.position.y },
width: previewSize.width,
height: previewSize.height,
metadata: { ...existingPreview?.metadata, ...imageMetadata(image), prompt: output.prompt, workflowKind: "reference_set", assetTags: ["导演台构图", `镜头:${sourceNode.title}`] },
};
if (previewIndex >= 0) nextNodes[previewIndex] = previewNode;
else nextNodes.push(previewNode);
let clayVideoId = sourceNode.metadata?.directorClayVideoNodeId;
if (videoUpload) {
clayVideoId ||= `video-director-clay-${Date.now()}`;
const videoIndex = nextNodes.findIndex((item) => item.id === clayVideoId);
const existingVideo = videoIndex >= 0 ? nextNodes[videoIndex] : null;
const videoNode: CanvasNodeData = {
...existingVideo,
id: clayVideoId,
type: CanvasNodeType.Video,
title: `${sourceNode.title} · 白膜视频`,
position: existingVideo?.position || { x: sourceNode.position.x, y: sourceNode.position.y + sourceNode.height + 48 },
width: existingVideo?.width || 360,
height: existingVideo?.height || 220,
metadata: { ...existingVideo?.metadata, ...videoMetadata(videoUpload), prompt: output.prompt, workflowKind: "reference_video", assetTags: ["导演台白膜", `镜头:${sourceNode.title}`] },
};
if (videoIndex >= 0) nextNodes[videoIndex] = videoNode;
else nextNodes.push(videoNode);
}
const nextConnections = [...connectionsRef.current];
[previewId, videoUpload ? clayVideoId : null].filter((id): id is string => Boolean(id)).forEach((id) => {
if (!nextConnections.some((connection) => connection.fromNodeId === id && connection.toNodeId === sourceNode.id)) nextConnections.push({ id: nanoid(), fromNodeId: id, toNodeId: sourceNode.id });
});
const retiredReferenceIds = new Set([sourceNode.metadata?.directorDepthNodeId, sourceNode.metadata?.directorNormalNodeId].filter(Boolean));
const referenceAssetNodeIds = Array.from(new Set([
...(sourceNode.metadata?.referenceAssetNodeIds || []).filter((id) => !retiredReferenceIds.has(id)),
previewId,
...(clayVideoId ? [clayVideoId] : []),
]));
const directorMetadata: Partial<CanvasNodeMetadata> = {
directorSceneId: output.scene.id,
directorShotId: output.shot.id,
directorPreviewNodeId: previewId,
directorDepthNodeId: undefined,
directorNormalNodeId: undefined,
directorClayVideoNodeId: clayVideoId,
composerContent: output.prompt,
prompt: output.prompt,
videoCameraMoveId: output.shot.cameraMove,
videoCameraMovePrompt: output.prompt,
referenceAssetNodeIds,
};
const finalizedNodes = nextNodes.map((item) => item.id === sourceNode.id ? { ...item, metadata: { ...item.metadata, ...directorMetadata } } : item);
nodesRef.current = finalizedNodes;
connectionsRef.current = nextConnections;
setNodes(finalizedNodes);
setConnections(nextConnections);
saveDirectorScene(mergedScene);
}, [connectionsRef, directorNodeId, nodesRef, projectId, saveDirectorScene, setConnections, setNodes]);
return { applyDirectorOutput, createDirectorShot, openDirectorWorkbench, saveDirectorScene };
}