fix(*): 生成任务 - 修复任务可见性、分镜重试与角色视频引用
This commit is contained in:
@@ -79,7 +79,7 @@ export async function executeVideoGeneration({
|
||||
|
||||
startGenerationRequest(videoId, nodeId, nodeId, controller);
|
||||
try {
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: videoId, mode: "video", prompt: effectivePrompt, config: generationConfig, referenceImages: generationContext.referenceImages, referenceVideos: generationContext.referenceVideos, referenceAudios: generationContext.referenceAudios, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(videoId, task) });
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: videoId, mode: "video", prompt: effectivePrompt, config: generationConfig, referenceImages: generationContext.referenceImages, referenceVideos: generationContext.referenceVideos, referenceAudios: generationContext.referenceAudios, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, resolvedCharacterVoices: generationContext.resolvedCharacterVoices, ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(videoId, task) });
|
||||
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
|
||||
const video = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
|
||||
const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
||||
|
||||
@@ -1291,7 +1291,18 @@ function InfiniteCanvasPage() {
|
||||
setHoveredNodeId((current) => (current === nodeId ? null : current));
|
||||
hideNodeToolbar();
|
||||
}, [hideNodeToolbar]);
|
||||
const retryCanvasNode = useCallback((node: CanvasNodeData) => { void handleRetryNode(node); }, [handleRetryNode]);
|
||||
const retryCanvasNode = useCallback((node: CanvasNodeData) => {
|
||||
if (node.type === CanvasNodeType.Script) {
|
||||
const prompt = (node.metadata?.composerContent || node.metadata?.prompt || "").trim();
|
||||
if (!prompt) {
|
||||
message.warning("分镜脚本缺少剧情内容,无法重试");
|
||||
return;
|
||||
}
|
||||
void generateScriptRows(node.id, prompt);
|
||||
return;
|
||||
}
|
||||
void handleRetryNode(node);
|
||||
}, [generateScriptRows, handleRetryNode, message]);
|
||||
const openCanvasNodeTaskDetails = useCallback((node: CanvasNodeData) => { void openNodeTaskDetails(node); }, [openNodeTaskDetails]);
|
||||
const openCanvasNodeVersions = useCallback((node: CanvasNodeData) => setVersionCompareRootId(node.metadata?.versionOfNodeId || node.id), []);
|
||||
const viewCanvasNodeImage = useCallback((node: CanvasNodeData) => setPreviewNodeId(node.id), []);
|
||||
|
||||
@@ -103,11 +103,15 @@ export function useCanvasConnectionController({
|
||||
setContextMenu(null);
|
||||
}, [connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
|
||||
|
||||
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => {
|
||||
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => {
|
||||
const storyboardRow = type === CanvasNodeType.Video ? storyboardRowFromHandle(nodesRef.current, pending.connection.nodeId, pending.connection.handleId) : undefined;
|
||||
const videoPrompt = storyboardRow ? (storyboardRow.videoMotionPrompt || storyboardRow.plotDescription).trim() : "";
|
||||
const sourceNode = pending.connection.handleType === "source" ? nodesRef.current.find((node) => node.id === pending.connection.nodeId) : undefined;
|
||||
const scriptPrompt = type === CanvasNodeType.Script && sourceNode?.type === CanvasNodeType.Text ? (sourceNode.metadata?.content || sourceNode.metadata?.prompt || "").trim() : "";
|
||||
const metadata = type === CanvasNodeType.Config
|
||||
? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count) }
|
||||
: type === CanvasNodeType.Script && scriptPrompt
|
||||
? { prompt: scriptPrompt, composerContent: scriptPrompt }
|
||||
: type === CanvasNodeType.Video && storyboardRow
|
||||
? { prompt: videoPrompt, composerContent: videoPrompt, generationMode: "video" as const, videoEditOperation: "text_to_video" as const, workflowKind: "shot" as const, workflowTitle: `镜头 ${storyboardRow.shotNumber} 视频`, shotIndex: storyboardRow.shotNumber, seconds: String(storyboardRow.durationSeconds), status: NODE_STATUS_IDLE }
|
||||
: undefined;
|
||||
@@ -119,9 +123,9 @@ export function useCanvasConnectionController({
|
||||
return;
|
||||
}
|
||||
if (type === CanvasNodeType.Drawing) {
|
||||
const sourceNode = nodesRef.current.find((node) => node.id === pending.connection.nodeId);
|
||||
const sourceUrl = sourceNode?.type === CanvasNodeType.Image ? sourceNode.metadata?.content : "";
|
||||
if (pending.connection.handleType !== "source" || !sourceNode || !sourceUrl || !newNode.metadata?.drawingId) {
|
||||
const drawingSourceNode = nodesRef.current.find((node) => node.id === pending.connection.nodeId);
|
||||
const sourceUrl = drawingSourceNode?.type === CanvasNodeType.Image ? drawingSourceNode.metadata?.content : "";
|
||||
if (pending.connection.handleType !== "source" || !drawingSourceNode || !sourceUrl || !newNode.metadata?.drawingId) {
|
||||
message.error("只有已有图片内容的输出连线可以创建绘图");
|
||||
return;
|
||||
}
|
||||
@@ -130,11 +134,11 @@ export function useCanvasConnectionController({
|
||||
try {
|
||||
const saved = await createCanvasDrawingFromImage(projectId, newNode.metadata.drawingId, {
|
||||
url: sourceUrl,
|
||||
storageKey: sourceNode.metadata?.storageKey,
|
||||
name: sourceNode.title || "来源图片",
|
||||
mimeType: sourceNode.metadata?.mimeType,
|
||||
storageKey: drawingSourceNode.metadata?.storageKey,
|
||||
name: drawingSourceNode.title || "来源图片",
|
||||
mimeType: drawingSourceNode.metadata?.mimeType,
|
||||
});
|
||||
newNode.title = `${sourceNode.title || "图片"} · 绘图`;
|
||||
newNode.title = `${drawingSourceNode.title || "图片"} · 绘图`;
|
||||
newNode.metadata = {
|
||||
...newNode.metadata,
|
||||
drawingRevision: saved.revision,
|
||||
@@ -155,7 +159,7 @@ export function useCanvasConnectionController({
|
||||
setSelectedNodeIds(new Set([newNode.id]));
|
||||
setSelectedConnectionId(null);
|
||||
if (type === CanvasNodeType.Drawing) setDrawingNodeId(newNode.id);
|
||||
else if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
||||
else if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
||||
closeConnectionCreateMenu();
|
||||
setConnecting(null);
|
||||
}, [closeConnectionCreateMenu, effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { App } from "antd";
|
||||
|
||||
import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation";
|
||||
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas-project-generation";
|
||||
import { buildGenerationConfig, isGenerationCanceled, supportsVideoReferenceAudio } from "@/lib/canvas/canvas-project-generation";
|
||||
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
|
||||
import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { generationFailureMetadata } from "@/lib/generation-error";
|
||||
@@ -110,6 +110,8 @@ export function useCanvasGenerationExecutor({
|
||||
buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : prompt),
|
||||
projectId,
|
||||
domainProjectId,
|
||||
mode,
|
||||
mode === "video" && supportsVideoReferenceAudio(generationConfig),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorDetails = error instanceof Error ? error.message : "生成任务准备失败";
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
resolveStoredReferenceImages,
|
||||
runBackendCanvasGenerationTask,
|
||||
sourceNodeReferenceImages,
|
||||
supportsVideoReferenceAudio,
|
||||
} from "@/lib/canvas/canvas-project-generation";
|
||||
import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { generationFailureMetadata, unchangedModeratedPrompt } from "@/lib/generation-error";
|
||||
@@ -57,11 +58,15 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, activated
|
||||
|
||||
return useCallback(
|
||||
async (node: CanvasNodeData) => {
|
||||
const retryMode = retryModeForNode(node.type);
|
||||
if (!retryMode) {
|
||||
message.warning("当前节点不能使用通用生成重试");
|
||||
return;
|
||||
}
|
||||
const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node;
|
||||
const batchRoot = node.metadata?.batchRootId ? nodesRef.current.find((item) => item.id === node.metadata?.batchRootId) : null;
|
||||
const savedImageMetadata = node.type === CanvasNodeType.Image ? { ...batchRoot?.metadata, ...node.metadata } : undefined;
|
||||
const hasSavedImageMetadata = Boolean(savedImageMetadata?.generationType);
|
||||
const retryMode: CanvasNodeGenerationMode = node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image";
|
||||
let generationConfig =
|
||||
hasSavedImageMetadata && savedImageMetadata
|
||||
? { ...effectiveConfig, model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model, quality: savedImageMetadata.quality || effectiveConfig.quality, size: savedImageMetadata.size || effectiveConfig.size, transparentBackground: (savedImageMetadata.transparentBackground || effectiveConfig.transparentBackground) === "true" ? "true" : "false", count: "1" }
|
||||
@@ -79,7 +84,7 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, activated
|
||||
let rawContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>> | null;
|
||||
try {
|
||||
const baseContext = buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, retryPromptSource);
|
||||
rawContext = hasSavedImageMetadata && !baseContext.characterReferences.length ? null : await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId);
|
||||
rawContext = hasSavedImageMetadata && !baseContext.characterReferences.length ? null : await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, retryMode, retryMode === "video" && supportsVideoReferenceAudio(generationConfig));
|
||||
} catch (error) {
|
||||
const failure = generationFailureMetadata(error, retryPromptSource);
|
||||
message.error(failure.errorDetails);
|
||||
@@ -141,7 +146,7 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, activated
|
||||
}
|
||||
if (node.type === CanvasNodeType.Video) {
|
||||
const videoGenerationMetadata = buildVideoGenerationMetadata(node, videoContext);
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "video", prompt, config: generationConfig, referenceImages: videoContext?.referenceImages || [], referenceVideos: videoContext?.referenceVideos || [], referenceAudios: videoContext?.referenceAudios || [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "video", prompt, config: generationConfig, referenceImages: videoContext?.referenceImages || [], referenceVideos: videoContext?.referenceVideos || [], referenceAudios: videoContext?.referenceAudios || [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], resolvedCharacterVoices: context?.resolvedCharacterVoices || [], ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
|
||||
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
|
||||
const video = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
|
||||
const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_WIDTH, VIDEO_NODE_MAX_HEIGHT);
|
||||
@@ -204,6 +209,15 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, activated
|
||||
);
|
||||
}
|
||||
|
||||
// 生成类型必须由明确的节点契约决定,未知节点不能降级成图片任务。
|
||||
function retryModeForNode(type: CanvasNodeType): CanvasNodeGenerationMode | null {
|
||||
if (type === CanvasNodeType.Text) return "text";
|
||||
if (type === CanvasNodeType.Image) return "image";
|
||||
if (type === CanvasNodeType.Video) return "video";
|
||||
if (type === CanvasNodeType.Audio) return "audio";
|
||||
return null;
|
||||
}
|
||||
|
||||
function markMissingReferences(nodeId: string, setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>) {
|
||||
setNodes((current) => current.map((item) => (item.id === nodeId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: "参考图片已丢失,无法继续重试" } } : item)));
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ import { formatCredits } from "@/constant/credits";
|
||||
import { listProjects, type ProjectSummary } from "@/services/api/projects";
|
||||
|
||||
const taskTableClassName = "app-data-table";
|
||||
type TaskStatusFilter = "failed" | "active" | "succeeded";
|
||||
type TaskStatusFilter = "all" | "failed" | "active" | "succeeded";
|
||||
|
||||
function taskStatusFilter(value: string | null): TaskStatusFilter {
|
||||
return value === "active" || value === "succeeded" ? value : "failed";
|
||||
return value === "failed" || value === "active" || value === "succeeded" ? value : "all";
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
@@ -61,6 +61,7 @@ export default function TasksPage() {
|
||||
return { label: projectName ? `${project.title || "未命名画布"} · ${projectName}` : project.title || "未命名画布", value: project.id };
|
||||
}), [domainProjectNameById, projects]);
|
||||
const filteredTasks = useMemo(() => tasks.filter((task) => {
|
||||
if (statusFilter === "all") return true;
|
||||
if (statusFilter === "active") return task.status === "queued" || task.status === "running";
|
||||
if (statusFilter === "failed") return task.status === "failed" || task.status === "cancelled";
|
||||
if (statusFilter === "succeeded") return task.status === "succeeded";
|
||||
@@ -178,6 +179,10 @@ export default function TasksPage() {
|
||||
try {
|
||||
const next = action === "retry" ? await retryGenerationTask(id) : await cancelGenerationTask(id);
|
||||
setTasks((items) => items.map((item) => (item.id === id ? next : item)));
|
||||
if (action === "retry") {
|
||||
setStatusFilter("active");
|
||||
setPage(1);
|
||||
}
|
||||
message.success(action === "retry" ? "任务已重新入队" : "任务已取消");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "操作失败");
|
||||
@@ -223,6 +228,8 @@ export default function TasksPage() {
|
||||
});
|
||||
setTasks((items) => [task, ...items]);
|
||||
}
|
||||
setStatusFilter("active");
|
||||
setPage(1);
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
message.success("任务已创建");
|
||||
@@ -364,6 +371,7 @@ export default function TasksPage() {
|
||||
value={statusFilter}
|
||||
onChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(1); }}
|
||||
options={[
|
||||
{ label: `全部 ${tasks.length}`, value: "all" },
|
||||
{ label: `需要处理 ${tasks.filter((task) => task.status === "failed" || task.status === "cancelled").length}`, value: "failed" },
|
||||
{ label: `运行中 ${tasks.filter((task) => task.status === "queued" || task.status === "running").length}`, value: "active" },
|
||||
{ label: `已完成 ${tasks.filter((task) => task.status === "succeeded").length}`, value: "succeeded" },
|
||||
@@ -536,6 +544,7 @@ function taskAttentionReason(task: GenerationTask) {
|
||||
}
|
||||
|
||||
function taskEmptyState(status: TaskStatusFilter) {
|
||||
if (status === "all") return { title: "还没有任务", description: "新提交的生成会在这里显示状态和实时进度。" };
|
||||
if (status === "active") return { title: "没有运行中的任务", description: "新提交的生成会在这里显示排队状态和实时进度。" };
|
||||
if (status === "succeeded") return { title: "还没有已完成任务", description: "生成成功后,结果预览和积分消耗会保留在这里。" };
|
||||
return { title: "目前没有需要处理的任务", description: "失败或取消的生成会出现在这里,并提供原因和可用操作。" };
|
||||
|
||||
Reference in New Issue
Block a user