From d7388fa9a9003380ada7bb0067e8055bcc4e6548 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Thu, 17 Sep 2026 00:08:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(app,canvas):=20=E5=9B=BE=E7=89=87=E6=A1=A3?= =?UTF-8?q?=E4=BD=8D=E5=B9=B6=E9=9B=86=E5=BF=BD=E7=95=A5=E5=81=9C=E7=94=A8?= =?UTF-8?q?=E7=BA=BF=E8=B7=AF=EF=BC=8C=E9=87=8D=E7=94=9F=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=BB=A7=E6=89=BF=E6=97=A7=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前台分辨率并集只收启用且权重大于 0 的供应线路,避免停用 4K 线路后仍出现不可路由档位。生成回写新媒体时清除旧 assetId,堵住原地重生后「旧素材 + 新资源」配对。 Co-authored-by: Cursor --- backend/internal/app/logical_models.go | 19 +++++++--- backend/internal/app/logical_models_test.go | 31 ++++++++++++++++ backend/internal/app/model_router.go | 6 +-- .../lib/canvas/canvas-generation-task-sync.ts | 19 ++++++++-- web/test/canvas-generation-task-sync.test.ts | 37 +++++++++++++++++++ 5 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 web/test/canvas-generation-task-sync.test.ts diff --git a/backend/internal/app/logical_models.go b/backend/internal/app/logical_models.go index 37310481b..ebb5fa08d 100644 --- a/backend/internal/app/logical_models.go +++ b/backend/internal/app/logical_models.go @@ -165,11 +165,7 @@ func (s *Service) PublicLogicalModels(intent *ModelRequestIntent) ([]PublicLogic func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogicalModel { item := cached.Model - routeSpecs := make([]CapabilitySpec, 0, len(cached.Routes)) - for _, route := range cached.Routes { - routeSpecs = append(routeSpecs, route.CapabilitySpec) - } - productSpec := capabilitySpecWithRoutePresets(cached.ProductSpec, routeSpecs) + productSpec := capabilitySpecWithRoutePresets(cached.ProductSpec, enabledLogicalRouteSpecs(cached.Routes)) profiles := make([]CapabilitySpec, 0, len(cached.Routes)) seen := make(map[string]bool, len(cached.Routes)) for _, route := range cached.Routes { @@ -253,9 +249,22 @@ func normalizeLegacyModelIDs(values []string) []string { return result } +func enabledLogicalRouteSpecs(routes []cachedLogicalRoute) []CapabilitySpec { + specs := make([]CapabilitySpec, 0, len(routes)) + for _, route := range routes { + if !route.Route.Enabled || route.Route.Weight <= 0 { + continue + } + specs = append(specs, route.CapabilitySpec) + } + return specs +} + // capabilitySpecWithRoutePresets repairs old front-model snapshots that stored // only `*` for a custom size. The wildcard remains for matching custom values, // while route presets are restored for admin and creator-side selectors. +// Callers must pass only currently enabled routes; disabled or zero-weight +// routes would otherwise advertise size tiers the public catalog cannot route. func capabilitySpecWithRoutePresets(spec CapabilitySpec, routes []CapabilitySpec) CapabilitySpec { result := spec result.Options = make(map[string]OptionConstraint, len(spec.Options)) diff --git a/backend/internal/app/logical_models_test.go b/backend/internal/app/logical_models_test.go index c9793d083..d36c4bcde 100644 --- a/backend/internal/app/logical_models_test.go +++ b/backend/internal/app/logical_models_test.go @@ -60,6 +60,37 @@ func TestCapabilitySpecWithRoutePresetsUnionsTiersAcrossRoutes(t *testing.T) { } } +func TestEnabledLogicalRouteSpecsOmitsDisabledAndZeroWeightTiers(t *testing.T) { + preset := func(size string, tier string, width int, height int) CapabilityImageSizePreset { + return CapabilityImageSizePreset{Size: size, Tier: tier, Ratio: "16:9", Width: width, Height: height} + } + product := CapabilitySpec{ + Version: 1, + Capability: "image", + ImageSize: &CapabilityImageSize{Parameter: "aspect_ratio", Presets: []CapabilityImageSizePreset{preset("1824x1024", "1k", 1824, 1024)}}, + } + routes := []cachedLogicalRoute{ + {Route: model.LogicalModelRoute{Enabled: true, Weight: 1}, CapabilitySpec: CapabilitySpec{ImageSize: &CapabilityImageSize{Presets: []CapabilityImageSizePreset{preset("2752x1536", "2k", 2752, 1536)}}}}, + {Route: model.LogicalModelRoute{Enabled: false, Weight: 1}, CapabilitySpec: CapabilitySpec{ImageSize: &CapabilityImageSize{Presets: []CapabilityImageSizePreset{preset("3840x2160", "4k", 3840, 2160)}}}}, + {Route: model.LogicalModelRoute{Enabled: true, Weight: 0}, CapabilitySpec: CapabilitySpec{ImageSize: &CapabilityImageSize{Presets: []CapabilityImageSizePreset{preset("4096x2304", "4k", 4096, 2304)}}}}, + } + + got := capabilitySpecWithRoutePresets(product, enabledLogicalRouteSpecs(routes)) + if got.ImageSize == nil { + t.Fatal("imageSize = nil") + } + tiers := make(map[string]bool, len(got.ImageSize.Presets)) + for _, item := range got.ImageSize.Presets { + tiers[item.Tier] = true + } + if !tiers["1k"] || !tiers["2k"] { + t.Fatalf("expected 1k and 2k from enabled routes: %#v", got.ImageSize.Presets) + } + if tiers["4k"] { + t.Fatalf("disabled and zero-weight 4k routes must not appear in public presets: %#v", got.ImageSize.Presets) + } +} + func TestMatchCapabilityRoutesTextImagesOnlyToDeclaredProfiles(t *testing.T) { plainText := CapabilitySpec{ Version: 1, diff --git a/backend/internal/app/model_router.go b/backend/internal/app/model_router.go index 35dab28d4..4d2860c02 100644 --- a/backend/internal/app/model_router.go +++ b/backend/internal/app/model_router.go @@ -634,11 +634,7 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) { } cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) } - routeSpecs := make([]CapabilitySpec, 0, len(cached.Routes)) - for _, route := range cached.Routes { - routeSpecs = append(routeSpecs, route.CapabilitySpec) - } - productSpec = capabilitySpecWithRoutePresets(productSpec, routeSpecs) + productSpec = capabilitySpecWithRoutePresets(productSpec, enabledLogicalRouteSpecs(cached.Routes)) defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec) if defaultsErr != nil { log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr) diff --git a/web/src/lib/canvas/canvas-generation-task-sync.ts b/web/src/lib/canvas/canvas-generation-task-sync.ts index 8a111fdeb..f5fe44eea 100644 --- a/web/src/lib/canvas/canvas-generation-task-sync.ts +++ b/web/src/lib/canvas/canvas-generation-task-sync.ts @@ -106,6 +106,19 @@ function workflowMetadataForResultNode(): Partial { }; } +// 原地重生会换 storageKey 但继承旧 assetId,形成「旧素材 + 新资源」配对,云端校验会永久拒绝。 +// 新媒体结果必须清掉旧绑定,交给入库/修复路径按新资源重绑。 +export function applyGeneratedMediaResultMetadata(node: CanvasNodeData, media: CanvasNodeMetadata, extra: Partial = {}): CanvasNodeMetadata { + return { + ...node.metadata, + ...workflowMetadataForResultNode(), + ...media, + ...extra, + errorDetails: undefined, + assetId: undefined, + }; +} + export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node]): Promise { const mode = generationTaskMode(task, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image"); const prompt = node.metadata?.prompt || task.prompt; @@ -143,7 +156,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: width: imageSize.width, height: imageSize.height, position: { x: node.position.x + node.width / 2 - imageSize.width / 2, y: node.position.y + node.height / 2 - imageSize.height / 2 }, - metadata: { ...node.metadata, ...workflowMetadataForResultNode(), ...imageMetadata(normalizedImage), prompt, ...completedTaskMetadata(task), errorDetails: undefined }, + metadata: applyGeneratedMediaResultMetadata(node, imageMetadata(normalizedImage), { prompt, ...completedTaskMetadata(task) }), }; } @@ -172,7 +185,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: ...node, type: CanvasNodeType.Video, ...geometry, - metadata: { ...node.metadata, ...workflowMetadataForResultNode(), ...videoMetadata(video), prompt, ...completedTaskMetadata(task), errorDetails: undefined }, + metadata: applyGeneratedMediaResultMetadata(node, videoMetadata(video), { prompt, ...completedTaskMetadata(task) }), }; } @@ -181,7 +194,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: const audio = result.audio.storageKey ? { url: await resolveMediaUrl(result.audio.storageKey, result.audio.dataUrl), storageKey: result.audio.storageKey, durationMs: result.audio.durationMs, bytes: result.audio.bytes || 0, mimeType: result.audio.mimeType || "audio/mpeg" } : await storeGeneratedAudio(await (await fetch(result.audio.dataUrl)).blob(), result.audio.format || "mp3"); - return { ...node, type: CanvasNodeType.Audio, metadata: { ...node.metadata, ...workflowMetadataForResultNode(), ...audioMetadata(audio), prompt, ...completedTaskMetadata(task), errorDetails: undefined } }; + return { ...node, type: CanvasNodeType.Audio, metadata: applyGeneratedMediaResultMetadata(node, audioMetadata(audio), { prompt, ...completedTaskMetadata(task) }) }; } if (!result.text) throw new Error("后端任务没有返回文本"); diff --git a/web/test/canvas-generation-task-sync.test.ts b/web/test/canvas-generation-task-sync.test.ts new file mode 100644 index 000000000..a6710b93d --- /dev/null +++ b/web/test/canvas-generation-task-sync.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; + +import { applyGeneratedMediaResultMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync"; +import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; + +function videoNode(assetId: string, storageKey: string): CanvasNodeData { + return { + id: "node-1", + type: CanvasNodeType.Video, + title: "镜头", + position: { x: 0, y: 0 }, + width: 320, + height: 180, + metadata: { assetId, storageKey, content: "https://example.test/old.mp4", prompt: "旧提示词" }, + }; +} + +describe("applyGeneratedMediaResultMetadata", () => { + test("clears the previous asset binding when a regenerated media result lands", () => { + const node = videoNode("asset-old", "video:old"); + const next = applyGeneratedMediaResultMetadata(node, videoMetadata({ + url: "https://example.test/new.mp4", + storageKey: "video:new", + width: 1280, + height: 720, + bytes: 12, + mimeType: "video/mp4", + durationMs: 4000, + }), { prompt: "新提示词" }); + + expect(next.assetId).toBeUndefined(); + expect(next.storageKey).toBe("video:new"); + expect(next.content).toBe("https://example.test/new.mp4"); + expect(next.prompt).toBe("新提示词"); + expect(next.status).toBe("success"); + }); +});