Files
ttstd-infinite-canvas/web/src/pages/canvas/use-canvas-generation-retry.ts

424 lines
25 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, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { buildEmotionImageArtifacts, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion";
import {
buildAudioGenerationMetadata,
buildGenerationConfig,
buildImageGenerationMetadata,
buildVideoGenerationMetadata,
createGenerationRetryContext,
findRetrySourceNode,
generationReferenceUrls,
generationWorkflowMetadata,
isGenerationCanceled,
canvasImageReferenceLimitError,
resolveMetadataReferences,
resolveStoredReferenceImages,
runBackendCanvasGenerationTask,
runCanvasGenerationTaskToConsumer,
sourceNodeReferenceImages,
supportsVideoReferenceAudio,
} from "@/lib/canvas/canvas-project-generation";
import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
import { generationFailureMetadata, unchangedModeratedPrompt } from "@/lib/generation-error";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { navigateToSettings } from "@/lib/settings-navigation";
import type { Skill } from "@/services/api/skills";
import { skillRuntime, type SkillRuntimeMetadata } from "@/services/skill-runtime";
import type { GenerationTask } from "@/services/api/task-center";
import { resolveImageUrl } from "@/services/image-storage";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import type { Asset } from "@/stores/use-asset-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeTypeId } from "@/types/canvas";
type UseCanvasGenerationRetryOptions = {
projectId: string;
domainProjectId?: string;
addedSkills: Skill[];
assets: Asset[];
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setRunningNodeId: Dispatch<SetStateAction<string | null>>;
startGenerationRequest: (targetNodeId: string, originNodeId: string, runningId?: string, controller?: AbortController) => AbortController;
finishGenerationRequest: (targetNodeId: string, controller: AbortController) => void;
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => void;
applyGenerationTaskResult: (targetNodeId: string, task: GenerationTask) => Promise<void>;
};
const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const;
export function useCanvasGenerationRetry({
projectId,
domainProjectId,
addedSkills,
assets,
nodesRef,
connectionsRef,
setNodes,
setRunningNodeId,
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask,
applyGenerationTaskResult,
}: UseCanvasGenerationRetryOptions) {
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
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 generationSourceNode = node.type === CanvasNodeType.Config && isCanvasWorkflowProvider(node.metadata) || node.metadata?.workflowProvider === "model" ? node : sourceNode;
const sourceGenerationConfig = buildGenerationConfig(effectiveConfig, generationSourceNode, retryMode);
let generationConfig =
hasSavedImageMetadata && savedImageMetadata
? {
...sourceGenerationConfig,
model: savedImageMetadata.model || effectiveConfig.imageModel || effectiveConfig.model || sourceGenerationConfig.model,
quality: savedImageMetadata.quality || effectiveConfig.quality || sourceGenerationConfig.quality,
size: savedImageMetadata.size || effectiveConfig.size || sourceGenerationConfig.size,
transparentBackground: (savedImageMetadata.transparentBackground || effectiveConfig.transparentBackground || sourceGenerationConfig.transparentBackground) === "true" ? "true" : "false",
count: "1",
}
: { ...sourceGenerationConfig, count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
return;
}
const retryPromptSource = sourceNode.metadata?.composerContent || sourceNode.metadata?.prompt || node.metadata?.prompt || "";
const retryContextPrompt = retryMode === "image" && sourceNode.metadata?.portraitTexture ? buildPortraitTexturePrompt(retryPromptSource, sourceNode.metadata.portraitTexture) : retryPromptSource;
if (unchangedModeratedPrompt(node.metadata, retryPromptSource)) {
message.warning("该提示词未通过内容审核,请先修改提示词再重新生成");
return;
}
let rawContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>> | null;
try {
const promptOnly = retryMode === "video";
const baseContext = buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, retryContextPrompt, assets, promptOnly);
rawContext =
hasSavedImageMetadata && !baseContext.characterReferences.length
? null
: await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, retryMode, retryMode === "video" && supportsVideoReferenceAudio(generationConfig), !promptOnly);
} catch (error) {
const failure = generationFailureMetadata(error, retryPromptSource);
message.error(failure.errorDetails);
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, ...failure } } : item)));
return;
}
let skillMetadata: SkillRuntimeMetadata = {
skillIds: node.metadata?.skillIds || [],
skillVersions: node.metadata?.skillVersions || [],
skillFiles: node.metadata?.skillFiles || [],
};
let context = rawContext;
if (rawContext) {
try {
const skillExecution = await skillRuntime.prepare({ profile: "canvas", prompt: rawContext.prompt, skills: addedSkills });
context = { ...rawContext, prompt: skillExecution.prompt };
skillMetadata = skillExecution.metadata;
} catch (error) {
message.error(error instanceof Error ? error.message : "技能上下文加载失败");
return;
}
}
const prompt = (context?.characterReferences.length ? context.prompt : savedImageMetadata?.prompt || context?.prompt || "").trim();
if (!prompt) {
message.warning("找不到提示词,无法重试");
return;
}
let mediaPrompt = prompt;
let styleMetadata = {};
if (node.type === CanvasNodeType.Image) {
try {
const runtime = resolveCanvasStyleExecution(nodesRef.current, sourceNode, prompt, generationConfig, "image");
if (runtime) {
mediaPrompt = runtime.prompt;
styleMetadata = { styleProfileJson: runtime.profileJson, styleExecutionPlan: runtime.plan };
}
} catch (error) {
message.error(error instanceof Error ? error.message : "项目画风与当前模型不兼容");
return;
}
}
if (retryMode === "audio" && context?.characterReferences.length) {
if (context.characterReferences.length !== 1) {
message.error("角色配音一次只能引用一个角色卡");
return;
}
const voice = context.resolvedCharacterVoices[0];
if (!voice) {
message.error("角色尚未绑定可用声音,无法重试角色配音任务");
return;
}
generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") };
}
const generationType = savedImageMetadata?.generationType;
const isEmotionRetry = Boolean(node.metadata?.emotionEdit);
if (isEmotionRetry && resolveModelRequestConfig(generationConfig, generationConfig.model).interfaceType !== "openai-image") {
message.error("表情编辑需要支持蒙版的 OpenAI Images 渠道,当前渠道已拒绝整图重绘");
return;
}
const useReferenceImages = isEmotionRetry ? false : context?.characterReferences.length ? true : generationType ? generationType === "edit" : Boolean(context?.referenceImages.length);
const retryReferenceImages = isEmotionRetry
? []
: hasSavedImageMetadata && savedImageMetadata && !context?.characterReferences.length
? await resolveMetadataReferences(savedImageMetadata)
: useReferenceImages
? context?.referenceImages.length
? context.referenceImages
: sourceNodeReferenceImages(batchRoot || sourceNode)
: [];
if (useReferenceImages && !retryReferenceImages) {
markMissingReferences(node.id, setNodes);
message.error("参考图片已丢失,无法继续重试");
return;
}
const retryImages = retryReferenceImages || [];
if (retryMode === "image") {
const referenceLimitError = canvasImageReferenceLimitError(generationConfig, retryImages);
if (referenceLimitError) {
message.error(referenceLimitError);
return;
}
}
const storedVideoImages = node.type === CanvasNodeType.Video && !context?.referenceImages.length ? await resolveStoredReferenceImages(node.metadata?.references) : [];
if (storedVideoImages === null) {
markMissingReferences(node.id, setNodes);
message.error("参考图片已丢失,无法继续重试");
return;
}
const videoReferenceImages = context?.referenceImages.length ? context.referenceImages : storedVideoImages;
const videoContext =
node.type === CanvasNodeType.Video
? {
prompt,
referenceImages: videoReferenceImages,
referenceVideos: context?.referenceVideos || [],
referenceAudios: context?.referenceAudios || [],
textCount: context?.textCount || 0,
imageCount: videoReferenceImages.length,
videoCount: context?.referenceVideos.length || 0,
audioCount: context?.referenceAudios.length || 0,
}
: undefined;
setRunningNodeId(node.id);
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined } } : item)));
const controller = startGenerationRequest(node.id, sourceNode.id, node.id);
const retryContext = node.metadata?.taskId ? await createGenerationRetryContext(node.metadata.taskId, node.metadata.attemptGroupId) : {};
const runAndConsumeRetry = async (input: Parameters<typeof runBackendCanvasGenerationTask>[0]) => {
await runCanvasGenerationTaskToConsumer(
{ ...input, ...retryContext },
{
bindTask: (task) => bindGenerationTask(node.id, task),
consumeTask: (task) => applyGenerationTaskResult(node.id, task),
},
);
};
try {
if (node.type === CanvasNodeType.Text) {
if (!context) return;
await runAndConsumeRetry({
projectId,
nodeId: node.id,
mode: "text",
prompt,
config: generationConfig,
referenceImages: context.referenceImages,
referenceVideos: context.referenceVideos,
signal: controller.signal,
metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context.resolvedCharacterVersions },
});
return;
}
if (node.type === CanvasNodeType.Video) {
const videoGenerationMetadata = buildVideoGenerationMetadata(node, videoContext, generationConfig);
setNodes((current) =>
current.map((item) =>
item.id === node.id
? {
...item,
metadata: {
...item.metadata,
prompt: mediaPrompt,
model: generationConfig.model,
size: generationConfig.size,
seconds: generationConfig.videoSeconds,
vquality: generationConfig.vquality,
generateAudio: generationConfig.videoGenerateAudio,
watermark: generationConfig.videoWatermark,
...videoGenerationMetadata,
...styleMetadata,
...skillMetadata,
references: videoContext ? generationReferenceUrls(videoContext) : item.metadata?.references,
},
}
: item,
),
);
await runAndConsumeRetry({
projectId,
nodeId: node.id,
mode: "video",
prompt: mediaPrompt,
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 || [],
promptTemplateOperation: node.metadata?.promptTemplateOperation,
promptTemplateVariables: node.metadata?.promptTemplateVariables,
...videoGenerationMetadata,
...styleMetadata,
...skillMetadata,
},
});
return;
}
if (node.type === CanvasNodeType.Audio) {
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, prompt, ...buildAudioGenerationMetadata(generationConfig) } } : item)));
await runAndConsumeRetry({
projectId,
nodeId: node.id,
mode: "audio",
prompt,
config: generationConfig,
signal: controller.signal,
metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], resolvedCharacterVoiceKey: context?.resolvedCharacterVoices[0]?.voiceKey },
});
return;
}
const emotionEdit = node.metadata?.emotionEdit;
if (emotionEdit) {
const emotionSource = nodesRef.current.find((item) => item.id === emotionEdit.sourceNodeId);
if (!emotionSource?.metadata?.content) throw new Error("情绪编辑源图片已删除,无法重试");
const sourceDataUrl = await resolveImageUrl(emotionSource.metadata.storageKey, emotionSource.metadata.content, { cacheMiss: true });
if (!sourceDataUrl) throw new Error("无法读取情绪编辑源图片");
const artifacts = await buildEmotionImageArtifacts(sourceDataUrl, emotionEdit.faceBox, emotionSource.metadata.naturalWidth || 0, emotionSource.metadata.naturalHeight || 0);
const emotionConfig = { ...generationConfig, size: emotionGenerationSize(artifacts.editRegion), quality: !generationConfig.quality || generationConfig.quality === "auto" ? "high" : generationConfig.quality };
const imageProfile = modelCapabilityConfigFor(emotionConfig, emotionConfig.model).image!;
const editPlan = resolveEmotionEditPlan(imageProfile.references.maskSupported);
const sourceReference = sourceNodeReferenceImages(emotionSource)[0];
if (!sourceReference) throw new Error("情绪编辑源图片不可用");
const editReference = { id: `${emotionSource.id}-${emotionEdit.presetId}-edit-region`, name: "emotion-edit-region.png", type: "image/png", dataUrl: artifacts.sourceDataUrl };
const characterReference = { id: `${emotionSource.id}-${emotionEdit.presetId}-character`, name: `${emotionEdit.characterName}-face.jpg`, type: "image/jpeg", dataUrl: artifacts.characterDataUrl };
const nextEmotionEdit = { ...emotionEdit, editRegion: artifacts.editRegion, sourceWidth: artifacts.imageWidth, sourceHeight: artifacts.imageHeight, providerSize: emotionConfig.size, editMode: editPlan.mode };
const providerPrompt = normalizeEmotionPromptForProvider(mediaPrompt);
const mask = emotionProviderMask(editPlan, { id: `${emotionSource.id}-emotion-mask`, name: "emotion-mask.png", type: "image/png", dataUrl: artifacts.maskDataUrl });
if (editPlan.notice) message.info(editPlan.notice);
const generationMetadata = { ...buildImageGenerationMetadata("edit", emotionConfig, 1, [sourceReference]), size: `${artifacts.imageWidth}x${artifacts.imageHeight}` };
setNodes((current) =>
current.map((item) =>
item.id === node.id
? {
...item,
metadata: { ...item.metadata, prompt: providerPrompt, ...generationMetadata, ...styleMetadata, ...skillMetadata, emotionEdit: nextEmotionEdit },
}
: item,
),
);
await runAndConsumeRetry({
projectId,
nodeId: node.id,
mode: "image",
prompt: providerPrompt,
config: emotionConfig,
referenceImages: [editReference, characterReference],
mask,
signal: controller.signal,
metadata: { retry: true, sourceNodeId: emotionSource.id, edit: "emotion", emotionEditMode: editPlan.mode, emotion: nextEmotionEdit, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], ...styleMetadata, ...skillMetadata },
});
return;
}
const generationMetadata = savedImageMetadata?.generationType
? {
generationType: savedImageMetadata.generationType,
model: generationConfig.model,
size: generationConfig.size,
quality: generationConfig.quality,
transparentBackground: generationConfig.transparentBackground,
count: savedImageMetadata.count || 1,
references: savedImageMetadata.references,
...generationWorkflowMetadata(generationConfig),
}
: buildImageGenerationMetadata(useReferenceImages ? "edit" : "generation", generationConfig, 1, retryImages);
setNodes((current) =>
current.map((item) =>
item.id === node.id
? {
...item,
metadata: { ...item.metadata, prompt: mediaPrompt, ...generationMetadata, ...styleMetadata, ...skillMetadata },
}
: item,
),
);
await runAndConsumeRetry({
projectId,
nodeId: node.id,
mode: "image",
prompt: mediaPrompt,
config: generationConfig,
referenceImages: useReferenceImages ? retryImages : [],
signal: controller.signal,
metadata: {
retry: true,
sourceNodeId: sourceNode.id,
resolvedCharacterVersions: context?.resolvedCharacterVersions || [],
promptTemplateOperation: node.metadata?.promptTemplateOperation,
promptTemplateVariables: node.metadata?.promptTemplateVariables,
...styleMetadata,
...skillMetadata,
},
});
} catch (error) {
if (isGenerationCanceled(error)) return;
const failure = generationFailureMetadata(error, retryPromptSource);
message.error(failure.errorDetails);
setNodes((current) => current.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, ...failure } } : item)));
} finally {
finishGenerationRequest(node.id, controller);
setRunningNodeId(null);
}
},
[addedSkills, applyGenerationTaskResult, assets, bindGenerationTask, connectionsRef, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, projectId, setNodes, setRunningNodeId, startGenerationRequest],
);
}
// 生成类型必须由明确的节点契约决定,未知节点不能降级成图片任务。
function retryModeForNode(type: CanvasNodeTypeId): 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)));
}