fix(app,canvas): 图片档位并集忽略停用线路,重生结果不再继承旧素材绑定
前台分辨率并集只收启用且权重大于 0 的供应线路,避免停用 4K 线路后仍出现不可路由档位。生成回写新媒体时清除旧 assetId,堵住原地重生后「旧素材 + 新资源」配对。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
ab6d6d1557
commit
d7388fa9a9
5 files changed
+99
-13
No files matched your search
@@ -165,11 +165,7 @@ func (s *Service) PublicLogicalModels(intent *ModelRequestIntent) ([]PublicLogic
|
|||||||
|
|
||||||
func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogicalModel {
|
func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogicalModel {
|
||||||
item := cached.Model
|
item := cached.Model
|
||||||
routeSpecs := make([]CapabilitySpec, 0, len(cached.Routes))
|
productSpec := capabilitySpecWithRoutePresets(cached.ProductSpec, enabledLogicalRouteSpecs(cached.Routes))
|
||||||
for _, route := range cached.Routes {
|
|
||||||
routeSpecs = append(routeSpecs, route.CapabilitySpec)
|
|
||||||
}
|
|
||||||
productSpec := capabilitySpecWithRoutePresets(cached.ProductSpec, routeSpecs)
|
|
||||||
profiles := make([]CapabilitySpec, 0, len(cached.Routes))
|
profiles := make([]CapabilitySpec, 0, len(cached.Routes))
|
||||||
seen := make(map[string]bool, len(cached.Routes))
|
seen := make(map[string]bool, len(cached.Routes))
|
||||||
for _, route := range cached.Routes {
|
for _, route := range cached.Routes {
|
||||||
@@ -253,9 +249,22 @@ func normalizeLegacyModelIDs(values []string) []string {
|
|||||||
return result
|
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
|
// capabilitySpecWithRoutePresets repairs old front-model snapshots that stored
|
||||||
// only `*` for a custom size. The wildcard remains for matching custom values,
|
// only `*` for a custom size. The wildcard remains for matching custom values,
|
||||||
// while route presets are restored for admin and creator-side selectors.
|
// 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 {
|
func capabilitySpecWithRoutePresets(spec CapabilitySpec, routes []CapabilitySpec) CapabilitySpec {
|
||||||
result := spec
|
result := spec
|
||||||
result.Options = make(map[string]OptionConstraint, len(spec.Options))
|
result.Options = make(map[string]OptionConstraint, len(spec.Options))
|
||||||
|
|||||||
@@ -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) {
|
func TestMatchCapabilityRoutesTextImagesOnlyToDeclaredProfiles(t *testing.T) {
|
||||||
plainText := CapabilitySpec{
|
plainText := CapabilitySpec{
|
||||||
Version: 1,
|
Version: 1,
|
||||||
|
|||||||
@@ -634,11 +634,7 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) {
|
|||||||
}
|
}
|
||||||
cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel})
|
cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel})
|
||||||
}
|
}
|
||||||
routeSpecs := make([]CapabilitySpec, 0, len(cached.Routes))
|
productSpec = capabilitySpecWithRoutePresets(productSpec, enabledLogicalRouteSpecs(cached.Routes))
|
||||||
for _, route := range cached.Routes {
|
|
||||||
routeSpecs = append(routeSpecs, route.CapabilitySpec)
|
|
||||||
}
|
|
||||||
productSpec = capabilitySpecWithRoutePresets(productSpec, routeSpecs)
|
|
||||||
defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
|
defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
|
||||||
if defaultsErr != nil {
|
if defaultsErr != nil {
|
||||||
log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr)
|
log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr)
|
||||||
|
|||||||
@@ -106,6 +106,19 @@ function workflowMetadataForResultNode(): Partial<CanvasNodeMetadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 原地重生会换 storageKey 但继承旧 assetId,形成「旧素材 + 新资源」配对,云端校验会永久拒绝。
|
||||||
|
// 新媒体结果必须清掉旧绑定,交给入库/修复路径按新资源重绑。
|
||||||
|
export function applyGeneratedMediaResultMetadata(node: CanvasNodeData, media: CanvasNodeMetadata, extra: Partial<CanvasNodeMetadata> = {}): CanvasNodeMetadata {
|
||||||
|
return {
|
||||||
|
...node.metadata,
|
||||||
|
...workflowMetadataForResultNode(),
|
||||||
|
...media,
|
||||||
|
...extra,
|
||||||
|
errorDetails: undefined,
|
||||||
|
assetId: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node]): Promise<CanvasNodeData> {
|
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node]): Promise<CanvasNodeData> {
|
||||||
const mode = generationTaskMode(task, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image");
|
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;
|
const prompt = node.metadata?.prompt || task.prompt;
|
||||||
@@ -143,7 +156,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
|
|||||||
width: imageSize.width,
|
width: imageSize.width,
|
||||||
height: imageSize.height,
|
height: imageSize.height,
|
||||||
position: { x: node.position.x + node.width / 2 - imageSize.width / 2, y: node.position.y + node.height / 2 - imageSize.height / 2 },
|
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,
|
...node,
|
||||||
type: CanvasNodeType.Video,
|
type: CanvasNodeType.Video,
|
||||||
...geometry,
|
...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
|
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" }
|
? { 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");
|
: 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("后端任务没有返回文本");
|
if (!result.text) throw new Error("后端任务没有返回文本");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user