diff --git a/web/src/lib/canvas/canvas-project-generation.ts b/web/src/lib/canvas/canvas-project-generation.ts index 074a81fd9..6d7c10985 100644 --- a/web/src/lib/canvas/canvas-project-generation.ts +++ b/web/src/lib/canvas/canvas-project-generation.ts @@ -1,6 +1,6 @@ import { type GenerationTask } from "@/services/api/task-center"; import { backendProviderConfig, runBackendGenerationTask } from "@/services/api/generation-task"; -import { defaultConfig, modelMatchesCapability, modelOptionName, normalizeModelOptionValue, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; +import { configuredModelMatchesCapability, defaultConfig, normalizeModelOptionValue, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import { resolveImageUrl, uploadImage } from "@/services/image-storage"; import { resolveMediaUrl } from "@/services/file-storage"; import { resourceIdFromStorageKey } from "@/services/api/resources"; @@ -316,7 +316,7 @@ export function resolveCanvasGenerationModel(config: AiConfig, model: string | u if (!model) return ""; const normalized = normalizeModelOptionValue(model, config.channels); if (!normalized) return ""; - return modelMatchesCapability(modelOptionName(normalized), mode) ? normalized : ""; + return configuredModelMatchesCapability(config, normalized, mode) ? normalized : ""; } export function supportsVideoReferenceAudio(config: AiConfig) { diff --git a/web/src/pages/canvas/canvas-media-generation-executors.ts b/web/src/pages/canvas/canvas-media-generation-executors.ts index 5673dac89..85ae652fa 100644 --- a/web/src/pages/canvas/canvas-media-generation-executors.ts +++ b/web/src/pages/canvas/canvas-media-generation-executors.ts @@ -35,6 +35,7 @@ export async function executeVideoGeneration({ const isEmptyVideoNode = sourceNode?.type === CanvasNodeType.Video && !sourceNode.metadata?.content; const isExistingVideoNode = sourceNode?.type === CanvasNodeType.Video && Boolean(sourceNode.metadata?.content); const videoId = isEmptyVideoNode ? nodeId : nanoid(); + const versionRootId = isExistingVideoNode && sourceNode ? sourceNode.metadata?.versionOfNodeId || sourceNode.id : undefined; const parent = sourceNode?.position || { x: 0, y: 0 }; const videoGenerationMetadata = buildVideoGenerationMetadata(sourceNode, generationContext); const videoNode: CanvasNodeData = { @@ -63,17 +64,19 @@ export async function executeVideoGeneration({ }, }; registerPendingNodeIds([videoId]); + // 待生成版本先加入版本族,但只有成功结果才能替换当前主版本。 setNodes((current) => { if (isEmptyVideoNode) return current.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node)); if (!isExistingVideoNode || !sourceNode) return [...current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode]; - const rootId = sourceNode.metadata?.versionOfNodeId || sourceNode.id; + const rootId = versionRootId!; const nextLabel = nextCanvasVersionLabel(rootId, current); + const hasPrimaryVersion = current.some((node) => (node.metadata?.versionOfNodeId || node.id) === rootId && node.metadata?.versionPrimary); return [ ...current.map((node) => { if ((node.metadata?.versionOfNodeId || node.id) !== rootId) return node; - return { ...node, metadata: { ...node.metadata, versionOfNodeId: rootId, versionLabel: node.metadata?.versionLabel || "A", versionPrimary: false, status: node.id === nodeId ? NODE_STATUS_SUCCESS : node.metadata?.status } }; + return { ...node, metadata: { ...node.metadata, versionOfNodeId: rootId, versionLabel: node.metadata?.versionLabel || "A", versionPrimary: node.metadata?.versionPrimary || (!hasPrimaryVersion && node.id === sourceNode.id), status: node.id === nodeId ? NODE_STATUS_SUCCESS : node.metadata?.status } }; }), - { ...videoNode, metadata: { ...videoNode.metadata, versionOfNodeId: rootId, versionLabel: nextLabel, versionPrimary: true } }, + { ...videoNode, metadata: { ...videoNode.metadata, versionOfNodeId: rootId, versionLabel: nextLabel, versionPrimary: false } }, ]; }); // 重新生成已有视频时,新节点继承源视频的上游连接,与源视频保持并行关系,而不是作为其下游子节点。 @@ -91,9 +94,10 @@ export async function executeVideoGeneration({ 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_SIZE.width, VIDEO_NODE_MAX_SIZE.height); setNodes((current) => current.map((node) => { - if (node.id !== videoId) return node; + const isVersionSibling = Boolean(versionRootId && (node.metadata?.versionOfNodeId || node.id) === versionRootId); + if (node.id !== videoId) return isVersionSibling ? { ...node, metadata: { ...node.metadata, versionPrimary: false } } : node; const geometry = node.metadata?.locked ? {} : { width: videoSize.width, height: videoSize.height, position: { x: node.position.x + node.width / 2 - videoSize.width / 2, y: node.position.y + node.height / 2 - videoSize.height / 2 } }; - return { ...node, ...geometry, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext), ...videoGenerationMetadata } }; + return { ...node, ...geometry, metadata: { ...node.metadata, ...videoMetadata(video), prompt: effectivePrompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark, references: generationReferenceUrls(generationContext), ...videoGenerationMetadata, ...(versionRootId ? { versionPrimary: true } : {}) } }; })); } finally { finishGenerationRequest(videoId, controller); diff --git a/web/src/pages/canvas/use-canvas-generation-executor.ts b/web/src/pages/canvas/use-canvas-generation-executor.ts index 8cbb1834a..6accce505 100644 --- a/web/src/pages/canvas/use-canvas-generation-executor.ts +++ b/web/src/pages/canvas/use-canvas-generation-executor.ts @@ -204,7 +204,8 @@ export function useCanvasGenerationExecutor({ return; } - const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode; + // 已有内容节点只是本次生成的来源;任务状态归新目标所有,不能覆盖已成功结果。 + const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode; const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt; if (!effectivePrompt && (mode === "text" || mode === "audio")) { finishGenerationRequest(nodeId, controller); diff --git a/web/test/canvas-model-policy.test.ts b/web/test/canvas-model-policy.test.ts index b2d9f78e1..84e1557a8 100644 --- a/web/test/canvas-model-policy.test.ts +++ b/web/test/canvas-model-policy.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { canvasConnectionError } from "../src/lib/canvas/canvas-connection-policy"; +import { buildGenerationConfig, resolveCanvasGenerationModel } from "../src/lib/canvas/canvas-project-generation"; import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; import { groupModelsByDisplayName, modelCompatibilityError, modelGroupReferenceLimits, resolveCompatibleModel } from "../src/lib/model-selection"; import { defaultConfig, type AiConfig, type ModelChannel } from "../src/stores/use-config-store"; @@ -52,6 +53,15 @@ function node(id: string, type: CanvasNodeType, generationMode?: "image" | "vide } describe("逻辑模型选择", () => { + test("后台标注的视频模型不因内部标识缺少视频关键词而回退", () => { + const config = policyConfig(); + const selectedModel = config.videoModels[0]!; + const videoNode = { ...node("video", CanvasNodeType.Video), metadata: { model: selectedModel } }; + + expect(resolveCanvasGenerationModel(config, selectedModel, "video")).toBe(selectedModel); + expect(buildGenerationConfig(config, videoNode, "video").model).toBe(selectedModel); + }); + test("同渠道同显示名称合并为一个逻辑模型", () => { const config = policyConfig(); const groups = groupModelsByDisplayName(config, config.videoModels);