import { type GenerationTask } from "@/services/api/task-center"; import { backendProviderConfig, logicalModelIDForConfig, runBackendGenerationTask, type GenerationTaskDependencies } from "@/services/api/generation-task"; import { configuredModelMatchesCapability, defaultConfig, normalizeModelOptionValue, normalizeRunningHubCapability, resolveModelRequestConfig, type AiConfig, type WorkflowFieldMapping } from "@/stores/use-config-store"; import { resolveImageUrl, uploadImage } from "@/services/image-storage"; import { resolveMediaUrl } from "@/services/file-storage"; import { resourceIdFromStorageKey } from "@/services/api/resources"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; import { isSeedanceVideoConfig } from "@/lib/seedance-video"; import { modelCapabilityConfigFor, workflowFieldCurrentValue, workflowFieldHasStoredValue, workflowFieldKey, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowOutputSizeValue, workflowVideoFieldsFromJson } from "@/lib/model-capabilities"; import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, resolveVideoOperation, type ModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection"; import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync"; import { producedModelCandidateForGeneration } from "@/lib/canvas/produced-model"; import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size"; import { interruptFileUpload } from "@/lib/canvas/canvas-file-upload"; import { isCanvasWorkflowProvider, resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow"; import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasVideoEditOperation } from "@/types/canvas"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; import { generationSpecMetadata, readNodeGenerationSpec, resolveGenerationSelection } from "@/lib/canvas/generation-contract"; export async function runBackendCanvasGenerationTask( { projectId, nodeId, mode, prompt, config, referenceImages = [], referenceVideos = [], referenceAudios = [], mask, signal, metadata, onTaskCreated, clientOperationId, retryOf, attemptGroupId, }: { projectId: string; nodeId: string; mode: CanvasNodeGenerationMode; prompt: string; config: AiConfig; referenceImages?: ReferenceImage[]; referenceVideos?: ReferenceVideo[]; referenceAudios?: ReferenceAudio[]; mask?: ReferenceImage; signal?: AbortSignal; metadata?: Record; onTaskCreated?: (task: GenerationTask) => void; clientOperationId?: string; retryOf?: string; attemptGroupId?: string; }, dependencies?: GenerationTaskDependencies, ) { if (mode === "image") assertCanvasImageReferenceLimit(config, referenceImages); return runBackendGenerationTask( { projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, mask, signal, metadata: { nodeId, ...(mode === "video" && !metadata?.videoEditOperation ? { videoEditOperation: "image_to_video" } : {}), ...metadata }, onTaskUpdate: onTaskCreated, clientOperationId, retryOf, attemptGroupId, }, dependencies, ); } export function canvasImageReferenceLimitError(config: AiConfig, referenceImages: ReferenceImage[]) { // 工作流的参考素材上限由字段映射槽位决定,不能再套用当前普通图片模型的能力表。 if ((config.taskWorkflowProvider || "model") !== "model") return ""; const maxImages = modelCapabilityConfigFor(config, config.model).image?.references.maxImages; if (maxImages === undefined || referenceImages.length <= maxImages) return ""; return `当前图片模型最多支持 ${maxImages} 张参考图,当前已连接 ${referenceImages.length} 张。请移除多余连线后重试`; } export function assertCanvasImageReferenceLimit(config: AiConfig, referenceImages: ReferenceImage[]) { const error = canvasImageReferenceLimitError(config, referenceImages); if (error) throw new Error(error); } export { backendProviderConfig }; // 兼容故事板执行器的旧导入路径。 export { logicalModelIDForConfig }; const generationOperationLocks = new Map>(); export function runGenerationOperationOnce(clientOperationId: string | undefined, operation: () => Promise): Promise { if (!clientOperationId) return operation(); const existing = generationOperationLocks.get(clientOperationId) as Promise | undefined; if (existing) return existing; const running = operation().finally(() => { if (generationOperationLocks.get(clientOperationId) === running) generationOperationLocks.delete(clientOperationId); }); generationOperationLocks.set(clientOperationId, running); return running; } export async function runCanvasGenerationTaskToConsumer( input: Parameters[0], dependencies: { bindTask(task: GenerationTask): void; consumeTask(task: GenerationTask): Promise; runTask?: (options: Parameters[0]) => ReturnType; }, ) { return runGenerationOperationOnce(input.clientOperationId, async () => { let completedTask: GenerationTask | undefined; const result = await (dependencies.runTask ?? runBackendCanvasGenerationTask)({ ...input, onTaskCreated: (task) => { input.onTaskCreated?.(task); dependencies.bindTask(task); if (task.status === "succeeded") completedTask = task; }, }); if (!completedTask) throw new Error("生成任务缺少成功终态"); await dependencies.consumeTask(completedTask); return result; }); } export type GenerationRetryContext = { retryOf: string; attemptGroupId: string; clientOperationId: string; }; export async function createGenerationRetryContext(retryOf: string, attemptGroupId = retryOf): Promise { const bytes = new TextEncoder().encode(`generation-retry\0${attemptGroupId}\0${retryOf}`); const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); const hex = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); return { retryOf, attemptGroupId, clientOperationId: `retry:${hex}` }; } export function createGenerationBatchRetryContexts(taskIds: readonly string[], attemptGroupId: string) { return Promise.all(taskIds.map((taskId) => createGenerationRetryContext(taskId, attemptGroupId))); } export function generationTaskMetadata(task: GenerationTask): CanvasNodeMetadata { const progress = normalizeTaskProgress(task.progress, task.status); return { taskId: task.id, taskClientOperationId: task.clientOperationId, retryOf: task.retryOf, attemptGroupId: task.attemptGroupId, taskStatus: task.status, taskProgress: progress, taskStage: task.stage, taskMediaStage: task.mediaStage, taskCanRecoverMedia: task.canRecoverMedia, taskProvider: task.provider, taskStartedAt: task.startedAt, taskCompletedAt: task.completedAt, taskDurationMs: task.startedAt && task.completedAt ? Math.max(0, Date.parse(task.completedAt) - Date.parse(task.startedAt)) : undefined, taskErrorCode: task.errorCode, taskOfficialStatus: task.officialStatus, taskReceiptRecorded: task.receiptRecorded, taskCreatedAt: task.createdAt || task.created_at, taskUpdatedAt: task.updatedAt || task.updated_at, }; } export { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state"; function normalizeTaskProgress(progress: number | undefined, status: GenerationTask["status"]) { if (typeof progress === "number" && Number.isFinite(progress)) return Math.max(0, Math.min(100, Math.round(progress))); if (status === "queued") return 0; if (status === "succeeded") return 100; return undefined; } export function imageExtension(dataUrl: string) { return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png"; } export function audioExtension(mimeType?: string) { if (mimeType?.includes("wav")) return "wav"; if (mimeType?.includes("opus")) return "opus"; if (mimeType?.includes("aac")) return "aac"; if (mimeType?.includes("flac")) return "flac"; if (mimeType?.includes("pcm")) return "pcm"; return "mp3"; } export function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata { return { ...generationWorkflowMetadata(config), generationType: type, model: config.model, producedModelCandidate: producedModelCandidateForGeneration(config), size: config.size, quality: config.quality, transparentBackground: config.transparentBackground, count, references: references.map(referenceUrl).filter((url): url is string => Boolean(url)), }; } export function nodeReferenceImage(node: CanvasNodeData): ReferenceImage | null { if (node.type === CanvasNodeType.MediaConversion) { // 转换结果只在成功物化后作为生成参考;运行中、跳过和失败状态都不能透传旧结果。 const conversion = node.metadata?.mediaConversion; const storageKey = conversion?.resultStorageKey || node.metadata?.storageKey; if (conversion?.status !== "completed" || !storageKey) return null; return { id: node.id, name: node.title || `conversion-${node.id}.png`, type: node.metadata?.mimeType || "image/png", dataUrl: node.metadata?.content || "", storageKey, }; } if (node.type !== CanvasNodeType.Image || (!node.metadata?.content && !node.metadata?.storageKey)) return null; return { id: node.id, name: `reference-${node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content || "", storageKey: node.metadata.storageKey, }; } export function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetadata { return { ...generationWorkflowMetadata(config), model: config.model, producedModelCandidate: producedModelCandidateForGeneration(config), audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: config.audioSpeed, audioLanguage: config.audioLanguage, audioDialect: config.audioDialect, audioInstructions: config.audioInstructions, audioEmotionControlMethod: config.audioEmotionControlMethod, audioEmotionRandom: config.audioEmotionRandom, audioEmotionHappy: config.audioEmotionHappy, audioEmotionAngry: config.audioEmotionAngry, audioEmotionSad: config.audioEmotionSad, audioEmotionAfraid: config.audioEmotionAfraid, audioEmotionDisgusted: config.audioEmotionDisgusted, audioEmotionMelancholic: config.audioEmotionMelancholic, audioEmotionSurprised: config.audioEmotionSurprised, audioEmotionCalm: config.audioEmotionCalm, }; } function referenceUrl(image: ReferenceImage) { return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined); } export async function resolveStoredReferenceImages(references?: string[]) { if (!references?.length) return []; const imageReferences = references.filter(isStoredImageReference); const images = await Promise.all( imageReferences.map(async (url, index) => { const storageKey = url.startsWith("image:") || resourceIdFromStorageKey(url) ? url : undefined; const dataUrl = storageKey ? await resolveImageUrl(storageKey, "") : url; if (!dataUrl) return null; return { id: `${index}`, name: `reference-${index + 1}.png`, type: imageMimeType(dataUrl), dataUrl, url: /^https?:\/\//i.test(dataUrl) ? dataUrl : undefined, storageKey, }; }), ); return images.every(Boolean) ? (images as ReferenceImage[]) : null; } function isStoredImageReference(url: string) { return resourceIdFromStorageKey(url) || url.startsWith("image:") || url.startsWith("data:image/") || /\.(png|jpe?g|webp|gif|avif)(?:[?#]|$)/i.test(url); } function imageMimeType(url: string) { return url.match(/^data:(image\/[^;,]+)/)?.[1] || "image/png"; } export function generationReferenceUrls(context: { referenceImages: ReferenceImage[]; referenceVideos: Array<{ storageKey?: string; url?: string }>; referenceAudios?: Array<{ storageKey?: string; url?: string }> }) { return [ ...context.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)), ...context.referenceVideos.map((video) => video.storageKey || video.url).filter((url): url is string => Boolean(url)), ...(context.referenceAudios || []).map((audio) => audio.storageKey || audio.url).filter((url): url is string => Boolean(url)), ]; } function resolveVideoEditOperation( node: CanvasNodeData | undefined, context?: { referenceImages: ReferenceImage[]; referenceVideos: ReferenceVideo[]; referenceAudios: ReferenceAudio[]; }, ): CanvasVideoEditOperation { const storedOperation = node?.metadata?.videoEditOperation; const input = { textCount: 0, imageCount: context?.referenceImages.length || 0, videoCount: context?.referenceVideos.length || 0, audioCount: context?.referenceAudios.length || 0, characterCount: 0, }; return resolveVideoOperation(input, storedOperation) as CanvasVideoEditOperation; } export function buildVideoGenerationMetadata( node: CanvasNodeData | undefined, context?: { referenceImages: ReferenceImage[]; referenceVideos: ReferenceVideo[]; referenceAudios: ReferenceAudio[]; }, config?: AiConfig, ): CanvasNodeMetadata { const metadata = node?.metadata; const referenceImageIds = new Set((context?.referenceImages || []).map((image) => image.id)); // 工作流视频把已连接媒体交给字段映射处理,不再把历史首尾帧选择当成硬约束。 // 工作流视频把已连接媒体交给字段映射处理,不再把历史首尾帧选择当成硬约束。 const workflowVideo = node?.type === CanvasNodeType.Config && (config?.taskWorkflowProvider === "runninghub" || isCanvasWorkflowProvider(metadata)); const startFrame = workflowVideo ? undefined : requireConnectedVideoFrame(metadata?.videoStartFrameNodeId, "首帧", referenceImageIds); const endFrame = workflowVideo ? undefined : requireConnectedVideoFrame(metadata?.videoEndFrameNodeId, "尾帧", referenceImageIds); return { ...(config ? generationWorkflowMetadata(config) : {}), videoEditOperation: resolveVideoEditOperation(node, context), videoCameraMoveId: metadata?.videoCameraMoveId, videoCameraMovePrompt: metadata?.videoCameraMovePrompt, videoStartFrameNodeId: startFrame, videoEndFrameNodeId: endFrame, }; } function requireConnectedVideoFrame(frameNodeId: string | undefined, label: string, referenceImageIds: Set) { if (!frameNodeId) return undefined; if (referenceImageIds.has(frameNodeId)) return frameNodeId; throw new Error(`已配置的${label}参考图未连接或不可用,请重新选择后再生成`); } export async function resolveMetadataReferences(metadata: CanvasNodeMetadata) { if (metadata.generationType !== "edit") return []; if (!metadata.references?.length) return null; return resolveStoredReferenceImages(metadata.references); } export async function hydrateCanvasImages(nodes: CanvasNodeData[]) { return Promise.all( nodes.map(async (node) => { const content = node.metadata?.content; const videoPreview = node.type === CanvasNodeType.Video && node.metadata?.videoPreview?.storageKey ? { ...node.metadata.videoPreview, content: await resolveImageUrl(node.metadata.videoPreview.storageKey, node.metadata.videoPreview.content, { cacheMiss: true }) } : node.metadata?.videoPreview; if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content), videoPreview } }; if (videoPreview !== node.metadata?.videoPreview) return { ...node, metadata: { ...node.metadata, videoPreview } }; if (node.type === CanvasNodeType.Image && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content, { cacheMiss: true }) } }; if (node.type !== CanvasNodeType.Image || !content) return node; if (!content.startsWith("data:image/")) return node; return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } }; }), ); } export async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) { const hydrateItem = async (item: T) => { if (item.storageKey) return { ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl) }; if (item.dataUrl?.startsWith("data:image/")) { const image = await uploadImage(item.dataUrl); return { ...item, dataUrl: image.url, storageKey: image.storageKey }; } return item; }; return Promise.all( sessions.map(async (session) => ({ ...session, messages: await Promise.all( session.messages.map(async (message) => ({ ...message, references: await Promise.all((message.references || []).map(hydrateItem)), })), ), })), ); } export function getGenerationCount(count: string) { return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1))); } export function generationWorkflowMetadata(config: AiConfig): Pick { const provider = config.taskWorkflowProvider || "model"; return { workflowProvider: provider, runningHubWorkflowId: provider === "runninghub" ? config.runningHub.workflowId : undefined, runningHubWorkflowKind: provider === "runninghub" ? config.runningHub.selectedKind : undefined, }; } export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode, requirements?: ModelRequirements, displayOnly = false): AiConfig { const generationSpec = node ? readNodeGenerationSpec(node) : undefined; const selection = generationSpec?.mode === mode ? generationSpec.modelSelection : undefined; const selectedModel = resolveGenerationSelection(config, selection); if (selection && !selectedModel && !displayOnly) throw new Error("节点选择的模型或渠道已不可用,请重新选择模型后生成"); if (node && generationSpec?.mode === mode) node = { ...node, metadata: { ...node.metadata, ...generationSpecMetadata(generationSpec) } }; // 只有独立 Config 节点读取工作流元数据;普通图片/视频/音频节点始终按基础模型生成。 const workflowProvider = mode !== "text" && node?.type === CanvasNodeType.Config && resolveCanvasWorkflowProvider(node.metadata) === "runninghub" ? "runninghub" : "model"; const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel; const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel; const storedModel = resolveCanvasGenerationModel(config, selectedModel || node?.metadata?.model, mode); if (selection && selectedModel && !storedModel && !displayOnly) throw new Error("节点选择的模型不支持当前生成模式,请重新选择"); const preferredModel = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel; // 先合并节点上的实时选择,再做兼容性匹配。否则路由只看到全局默认值,节点改过的时长、分辨率或布尔能力无法参与分流。 const workflowParameters = node?.metadata?.workflowParameters || {}; const runningHubWorkflowId = node?.metadata?.runningHubWorkflowId?.trim() || config.runningHub.workflowId.trim(); const selectedRunningHubWorkflow = workflowProvider === "runninghub" ? config.runningHub.workflows.find((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind)) : undefined; const selectedWorkflowFields = workflowProvider === "runninghub" ? selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson) : []; const workflowOutputSize = workflowProvider === "model" ? "" : workflowOutputSizeValue(selectedWorkflowFields, workflowParameters); const workflowParameterValue = (source: string) => { const value = workflowParameters[`source:${source}`]; return value === undefined || value === null ? undefined : String(value); }; const requestedConfig: AiConfig = { ...config, quality: node?.metadata?.quality ?? workflowParameterValue("quality") ?? config.quality ?? defaultConfig.quality, size: workflowProvider === "model" ? node?.metadata?.size ?? config.size ?? defaultConfig.size : workflowOutputSize || node?.metadata?.size || config.size || defaultConfig.size, transparentBackground: node?.metadata?.transparentBackground ?? config.transparentBackground ?? defaultConfig.transparentBackground, videoSeconds: workflowProvider === "model" ? normalizeVideoDuration(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds) : String(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds), vquality: workflowProvider === "model" ? normalizeVideoResolution(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality) : String(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality), videoGenerateAudio: node?.metadata?.generateAudio ?? config.videoGenerateAudio ?? defaultConfig.videoGenerateAudio, videoWatermark: node?.metadata?.watermark ?? config.videoWatermark ?? defaultConfig.videoWatermark, videoArkPrivateAssetUpload: node?.metadata?.arkPrivateAssetUpload ?? config.videoArkPrivateAssetUpload ?? defaultConfig.videoArkPrivateAssetUpload, audioVoice: node?.metadata?.audioVoice ?? config.audioVoice ?? defaultConfig.audioVoice, audioFormat: node?.metadata?.audioFormat ?? config.audioFormat ?? defaultConfig.audioFormat, audioSpeed: node?.metadata?.audioSpeed ?? config.audioSpeed ?? defaultConfig.audioSpeed, audioLanguage: node?.metadata?.audioLanguage ?? config.audioLanguage ?? defaultConfig.audioLanguage, audioDialect: node?.metadata?.audioDialect ?? config.audioDialect ?? defaultConfig.audioDialect, audioInstructions: node?.metadata?.audioInstructions ?? config.audioInstructions ?? defaultConfig.audioInstructions, count: String(node?.metadata?.count ?? (mode === "image" ? config.canvasImageCount || config.count || defaultConfig.count : config.count || defaultConfig.count)), }; const imageSize = mode === "image" ? requestedConfig.size : undefined; // 无 requirements 的调用(重试、媒体工具等)也按当前能力与尺寸路由到组内最低价兼容模型, // 避免旧 metadata.model 不支持当前尺寸导致生成时被 normalize 回退。 const liveOptions = modelRequestOptions(requestedConfig, mode); const baseRequirements = requirements?.capability ? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } } : { capability: mode, options: liveOptions }; const model = workflowProvider === "model" ? resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel : preferredModel; const generationDefaults: Partial = workflowProvider === "model" ? resolveModelGenerationDefaults( config, model, mode === "image" ? "image" : mode === "video" ? "video" : undefined, mode === "image" ? { size: node?.metadata?.size, quality: node?.metadata?.quality, transparentBackground: node?.metadata?.transparentBackground, count: requestedConfig.count, } : { size: node?.metadata?.size, videoSeconds: node?.metadata?.seconds, vquality: node?.metadata?.vquality, videoGenerateAudio: node?.metadata?.generateAudio, videoWatermark: node?.metadata?.watermark, }, { size: requestedConfig.size, quality: requestedConfig.quality, transparentBackground: requestedConfig.transparentBackground, count: requestedConfig.count, videoSeconds: requestedConfig.videoSeconds, vquality: requestedConfig.vquality, videoGenerateAudio: requestedConfig.videoGenerateAudio, videoWatermark: requestedConfig.videoWatermark, }, ) : {}; const modeCapability = mode === "video" || mode === "audio" ? mode : "image"; const runningHubCapability = normalizeRunningHubCapability(selectedRunningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability)); const runningHub = { ...config.runningHub, enabled: workflowProvider === "runninghub" && config.runningHub.enabled, selectedKind: selectedRunningHubWorkflow?.kind === "app" ? "app" as const : "workflow" as const, workflowId: runningHubWorkflowId, capability: runningHubCapability, workflows: workflowProvider === "runninghub" ? config.runningHub.workflows.map((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind) ? { ...item, fields: applyWorkflowParameterValues(item.fields?.length ? item.fields : workflowVideoFieldsFromJson(item.workflowJson) as WorkflowFieldMapping[], workflowParameters) } : item) : config.runningHub.workflows }; return { ...requestedConfig, taskWorkflowProvider: workflowProvider, runningHub, model, quality: generationDefaults.quality || requestedConfig.quality, size: generationDefaults.size ?? requestedConfig.size, transparentBackground: generationDefaults.transparentBackground || (requestedConfig.transparentBackground === "true" ? "true" : "false"), videoSeconds: generationDefaults.videoSeconds ?? requestedConfig.videoSeconds, vquality: generationDefaults.vquality ?? requestedConfig.vquality, videoGenerateAudio: generationDefaults.videoGenerateAudio ?? requestedConfig.videoGenerateAudio, videoWatermark: generationDefaults.videoWatermark ?? requestedConfig.videoWatermark, videoArkPrivateAssetUpload: requestedConfig.videoArkPrivateAssetUpload, count: generationDefaults.count || requestedConfig.count, }; } export function resolveCanvasGenerationModel(config: AiConfig, model: string | undefined, mode: CanvasNodeGenerationMode): string { if (!model) return ""; const normalized = normalizeModelOptionValue(model, config.channels); if (!normalized) return ""; return configuredModelMatchesCapability(config, normalized, mode) ? normalized : ""; } function applyWorkflowParameterValues(fields: WorkflowFieldMapping[] | undefined, values: Record) { if (!fields?.length || !Object.keys(values).length) return fields; return fields.map((field) => { const key = workflowFieldKey(field); const randomKey = workflowFieldRandomKey(field); const hasValue = workflowFieldHasStoredValue(field, values); const hasRandom = Object.prototype.hasOwnProperty.call(values, randomKey); if (!hasValue && !hasRandom) return field; const value = workflowFieldSubmissionValue(field, workflowFieldCurrentValue(field, values)); // 画布动态参数按具体字段覆盖,不能继续走 aspectRatio/quality 等全局来源, // 否则同一工作流中的两个同类参数会被折叠成同一个值。 return { ...field, ...(hasValue ? { fieldValue: value, value } : {}), source: "", sourceAutomatic: false, sourceFromUpstream: false, randomEnabled: hasRandom ? values[randomKey] === true : hasValue ? false : field.randomEnabled, }; }); } export function supportsVideoReferenceAudio(config: AiConfig) { const interfaceType = resolveModelRequestConfig(config, config.model).interfaceType; return interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2" || isSeedanceVideoConfig(config); } export function resetInterruptedGeneration(nodes: CanvasNodeData[]) { const configWidth = NODE_DEFAULT_SIZE[CanvasNodeType.Config].width; const configHeight = NODE_DEFAULT_SIZE[CanvasNodeType.Config].height; let changed = false; const reset = nodes.map((node) => { const mediaNode = ensureMediaNodeMinimumSize(interruptFileUpload(node)); const resizedNode = mediaNode.type === CanvasNodeType.Config && (mediaNode.width < configWidth || mediaNode.height < configHeight) ? { ...mediaNode, width: Math.max(mediaNode.width, configWidth), height: Math.max(mediaNode.height, configHeight) } : mediaNode.type === CanvasNodeType.Script && mediaNode.height < NODE_DEFAULT_SIZE[CanvasNodeType.Script].height ? { ...mediaNode, height: NODE_DEFAULT_SIZE[CanvasNodeType.Script].height } : mediaNode; const restoredNode = resizedNode.metadata?.status === "loading" ? { ...resizedNode, metadata: { ...resizedNode.metadata, errorDetails: "正在从任务中心恢复生成状态..." } } : resizedNode; if (restoredNode !== node) changed = true; return restoredNode; }); return changed ? reset : nodes; } export function isGenerationCanceled(error: unknown) { return error instanceof Error && (error.message === "请求已取消" || error.name === "AbortError"); } export function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) { const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId); const visited = new Set(); while (queue.length) { const id = queue.shift()!; if (visited.has(id)) continue; visited.add(id); const node = nodes.find((item) => item.id === id); if (node?.type === CanvasNodeType.Config) return node; connections.filter((connection) => connection.toNodeId === id).forEach((connection) => queue.push(connection.fromNodeId)); } return null; } export function sourceNodeReferenceImages(node: CanvasNodeData | null) { const reference = node ? nodeReferenceImage(node) : null; return reference ? [reference] : []; } export function isAudioFile(file: File) { return file.type.startsWith("audio/") || /\.(mp3|wav)$/i.test(file.name); }