Files
ttstd-infinite-canvas/web/src/pages/canvas/use-canvas-generation.ts
ddcat666 ea15000541 feat: 完成短剧项目空间与结构化资产创作闭环 (#38)
* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP

* feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环

Refs #30

* fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置

* feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环

Refs #30

---------

Co-authored-by: ddCat <ddcat666@88.com>
2026-07-27 10:45:11 +08:00

234 lines
13 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { applyGenerationTaskResultToNodes, generationTaskNodeId } from "@/lib/canvas/canvas-generation-task-sync";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { cancelGenerationTask, listGenerationTasks, listTaskLogs, queryGenerationTask, waitForGenerationTask, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { cinematicStoryboardColumns, storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
import { generationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { generationFailureMetadata } from "@/lib/generation-error";
type CanvasGenerationRequest = {
targetNodeId: string;
originNodeId: string;
runningNodeId: string;
controller: AbortController;
};
type UseCanvasGenerationOptions = {
projectId: string;
domainProjectId?: string;
projectLoaded: boolean;
nodes: CanvasNodeData[];
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
};
const NODE_STATUS_IDLE = "idle" as const;
const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const;
export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded, nodes, nodesRef, setNodes }: UseCanvasGenerationOptions) {
const { message, modal } = App.useApp();
const queryClient = useQueryClient();
const generationRequestsRef = useRef(new Map<string, CanvasGenerationRequest>());
const recoveringTaskIdsRef = useRef(new Set<string>());
const autoSavedTaskIdsRef = useRef(new Set<string>());
const [runningNodeId, setRunningNodeId] = useState<string | null>(null);
const [taskDetail, setTaskDetail] = useState<GenerationTask | null>(null);
const [taskDetailLogs, setTaskDetailLogs] = useState<TaskLog[]>([]);
const [taskDetailLoading, setTaskDetailLoading] = useState(false);
const startGenerationRequest = useCallback((targetNodeId: string, originNodeId: string, runningId = originNodeId, controller = new AbortController()) => {
const previous = generationRequestsRef.current.get(targetNodeId);
if (previous?.controller !== controller) previous?.controller.abort();
generationRequestsRef.current.set(targetNodeId, { targetNodeId, originNodeId, runningNodeId: runningId, controller });
return controller;
}, []);
const finishGenerationRequest = useCallback((targetNodeId: string, controller: AbortController) => {
const request = generationRequestsRef.current.get(targetNodeId);
if (request?.controller === controller) generationRequestsRef.current.delete(targetNodeId);
}, []);
const stopGenerationByRunningId = useCallback((runningId: string) => {
const affectedNodeIds = new Set<string>();
generationRequestsRef.current.forEach((request) => {
if (request.runningNodeId !== runningId) return;
request.controller.abort();
generationRequestsRef.current.delete(request.targetNodeId);
affectedNodeIds.add(request.targetNodeId);
affectedNodeIds.add(request.originNodeId);
});
setRunningNodeId((current) => (current === runningId ? null : current));
if (!affectedNodeIds.size) return;
setNodes((current) => current.map((node) => affectedNodeIds.has(node.id) && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node));
}, [setNodes]);
const confirmStopGeneration = useCallback((nodeId: string) => {
modal.confirm({
title: "停止生成?",
content: "当前生成请求会被中断,已经生成完成的内容会保留。",
okText: "停止",
cancelText: "继续生成",
okButtonProps: { danger: true },
onOk: () => stopGenerationByRunningId(nodeId),
});
}, [modal, stopGenerationByRunningId]);
const cancelNodeTask = useCallback((node: CanvasNodeData) => {
const taskId = node.metadata?.taskId;
if (!taskId) {
confirmStopGeneration(node.id);
return;
}
modal.confirm({
title: "取消后台任务?",
content: "任务会在后端停止,已生成完成的内容仍会保留。",
okText: "取消任务",
cancelText: "继续生成",
okButtonProps: { danger: true },
onOk: async () => {
generationRequestsRef.current.get(node.id)?.controller.abort();
const task = await cancelGenerationTask(taskId);
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, ...generationTaskMetadata(task), status: NODE_STATUS_ERROR, errorDetails: "任务已取消" } } : item));
message.success("任务已取消");
},
});
}, [confirmStopGeneration, message, modal, setNodes]);
const openNodeTaskDetails = useCallback(async (node: CanvasNodeData) => {
const taskId = node.metadata?.taskId;
if (!taskId) return;
setTaskDetailLoading(true);
setTaskDetailLogs([]);
setTaskDetail({
id: taskId,
type: "",
status: (node.metadata?.taskStatus as GenerationTask["status"]) || "running",
stage: node.metadata?.taskStage,
progress: node.metadata?.taskProgress,
prompt: node.metadata?.prompt || "",
attempts: 1,
createdAt: node.metadata?.taskCreatedAt || new Date().toISOString(),
updatedAt: node.metadata?.taskUpdatedAt || new Date().toISOString(),
});
try {
const [task, logs] = await Promise.all([queryGenerationTask(taskId), listTaskLogs(taskId)]);
setTaskDetail(task);
setTaskDetailLogs(logs);
} catch (error) {
message.error(error instanceof Error ? error.message : "任务详情加载失败");
} finally {
setTaskDetailLoading(false);
}
}, [message]);
const bindGenerationTask = useCallback((targetNodeId: string, task: GenerationTask) => {
setNodes((current) => current.map((node) => {
if (node.id !== targetNodeId) return node;
const failed = task.status === "failed" || task.status === "cancelled";
const hasCompletedContent = task.status === "succeeded" && Boolean(node.metadata?.content);
const failure = failed
? generationFailureMetadata(task.error || (task.status === "cancelled" ? "任务已取消" : "任务失败"), node.metadata?.composerContent || node.metadata?.prompt || task.prompt || "")
: undefined;
return {
...node,
metadata: {
...node.metadata,
...generationTaskMetadata(task),
status: failed ? NODE_STATUS_ERROR : hasCompletedContent ? NODE_STATUS_SUCCESS : NODE_STATUS_LOADING,
...(failure || { errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined }),
},
};
}));
}, [setNodes]);
const saveGeneratedAsset = useCallback(async (node: CanvasNodeData, taskId: string) => {
const result = await ensureCanvasNodeAsset({ canvasId: projectId, domainProjectId, node, source: "canvas-generation", taskId });
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, assetId: result.assetId } } : item));
if (domainProjectId) await queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
}, [domainProjectId, projectId, queryClient, setNodes]);
const applyGenerationTaskResult = useCallback(async (nodeId: string, task: GenerationTask) => {
const applied = await applyGenerationTaskResultToNodes(nodesRef.current, task, nodeId);
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
setNodes((current) => current.map((node) => node.id === applied.nodeId ? applied.node! : node));
}, [nodesRef, setNodes]);
const recoverInterruptedGenerationTasks = useCallback(async () => {
const recoveryNodes = nodesRef.current.filter((node) => node.metadata?.status === NODE_STATUS_LOADING || node.metadata?.errorDetails === "页面刷新后生成已中断,请重新生成。" || Boolean(node.metadata?.taskId && node.metadata.status !== NODE_STATUS_SUCCESS));
if (!recoveryNodes.length) return;
const taskIds = Array.from(new Set(recoveryNodes.map((node) => node.metadata?.taskId).filter((id): id is string => Boolean(id))));
const tasks = (await Promise.all(taskIds.map((id) => queryGenerationTask(id).catch(() => undefined)))).filter((task): task is GenerationTask => Boolean(task));
if (recoveryNodes.some((node) => !node.metadata?.taskId)) {
const recentTasks = await listGenerationTasks(30).catch(() => []);
tasks.push(...recentTasks.filter((task) => !tasks.some((item) => item.id === task.id)));
}
const projectTasks = tasks.filter((task) => task.projectId === projectId && (task.type.startsWith("canvas_") || task.type === "agent_storyboard_rows"));
await Promise.all(recoveryNodes.map(async (node) => {
let task = projectTasks.find((item) => item.id === node.metadata?.taskId) || projectTasks.find((item) => generationTaskNodeId(item) === node.id);
if (!task && node.metadata?.taskId) task = await queryGenerationTask(node.metadata.taskId).catch(() => undefined);
if (!task) {
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "页面刷新后找不到对应任务,请重新生成。" } } : item));
return;
}
if (recoveringTaskIdsRef.current.has(task.id)) return;
recoveringTaskIdsRef.current.add(task.id);
bindGenerationTask(node.id, task);
try {
const completed = task.status === "succeeded" ? task : await waitForGenerationTask(task.id, { initialTask: task });
if (node.type === CanvasNodeType.Script && completed.type === "agent_storyboard_rows") {
const result = storyboardRowsFromTask(completed);
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, title: result.title || item.title, metadata: { ...item.metadata, ...generationTaskMetadata(completed), status: NODE_STATUS_SUCCESS, errorDetails: undefined, generationErrorCode: undefined, failedPromptFingerprint: undefined, storyboard: { rows: result.rows, visibleColumns: cinematicStoryboardColumns(item.metadata?.storyboard?.visibleColumns), referenceNodeIds: item.metadata?.storyboard?.referenceNodeIds || [] } } } : item));
} else {
await applyGenerationTaskResult(node.id, completed);
}
} catch (error) {
const failure = generationFailureMetadata(error, node.metadata?.composerContent || node.metadata?.prompt || task.prompt || "");
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, ...failure } } : item));
} finally {
recoveringTaskIdsRef.current.delete(task.id);
}
}));
}, [applyGenerationTaskResult, bindGenerationTask, nodesRef, projectId, setNodes]);
useEffect(() => {
if (!projectLoaded) return;
void recoverInterruptedGenerationTasks();
}, [projectLoaded, recoverInterruptedGenerationTasks]);
useEffect(() => {
if (!projectLoaded) return;
nodes.forEach((node) => {
const taskId = node.metadata?.taskId;
if (!taskId || !node.metadata?.content || node.metadata.status !== NODE_STATUS_SUCCESS || (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio)) return;
const saveKey = `${taskId}:${node.id}:${domainProjectId || "personal"}`;
if (autoSavedTaskIdsRef.current.has(saveKey)) return;
autoSavedTaskIdsRef.current.add(saveKey);
void saveGeneratedAsset(node, taskId).catch((error) => {
autoSavedTaskIdsRef.current.delete(saveKey);
message.warning(error instanceof Error ? `生成结果已保留,但项目资产同步失败:${error.message}` : "生成结果已保留,但项目资产同步失败");
});
});
}, [domainProjectId, message, nodes, projectLoaded, saveGeneratedAsset]);
return {
bindGenerationTask,
cancelNodeTask,
confirmStopGeneration,
finishGenerationRequest,
openNodeTaskDetails,
runningNodeId,
setRunningNodeId,
setTaskDetail,
startGenerationRequest,
taskDetail,
taskDetailLoading,
taskDetailLogs,
};
}