feat(protocol): 可美 KM Seedream - 接入图片生成与编辑

This commit is contained in:
Codex
2026-10-09 16:42:58 +08:00
parent 85c2aa178b
commit 1b08bd64dd
24 changed files with 3277 additions and 21 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -27,6 +27,19 @@ func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapab
MaxOutputs: 15,
}
switch model.ChannelInterfaceType(protocol) {
case "km-kemei-seedream":
image.References.MaxImages = 10
image.References.MaskSupported = false
presets := kemeiSeedreamImagePresets()
values := make([]string, 0, len(presets))
for _, preset := range presets {
values = append(values, preset.Size)
}
image.Size = ImageSizeConfig{Parameter: "size", Values: values, Default: "2048x2048", AllowCustom: true, Presets: presets}
image.Quality = ImageQualityConfig{Supported: false, Values: []string{}, Default: "auto"}
// 透明背景需要带 alpha 的单张参考图,通过插件命名空间显式配置。
image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false}
image.MaxOutputs = 1
case model.ChannelInterfaceGrokImage:
image.References.MaxImages = 1
image.References.MaskSupported = false

View File

@@ -0,0 +1,29 @@
package app
import (
"strconv"
"strings"
)
// 供应商与 Seedream 5.0 官方文档的精确尺寸;档位不能由通用像素阈值推断。
func kemeiSeedreamImagePresets() []ImageSizePreset {
ratios := []string{"1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9"}
tiers := []struct {
name string
sizes []string
}{
{"1k", []string{"1024x1024", "1152x864", "864x1152", "1424x800", "800x1424", "1248x832", "832x1248", "1568x672"}},
{"1.5k", []string{"1536x1536", "1792x1344", "1344x1792", "2048x1152", "1152x2048", "1872x1248", "1248x1872", "2352x1008"}},
{"2k", []string{"2048x2048", "2368x1776", "1776x2368", "2816x1584", "1584x2816", "2496x1664", "1664x2496", "3136x1344"}},
}
result := make([]ImageSizePreset, 0, 24)
for _, tier := range tiers {
for index, size := range tier.sizes {
parts := strings.Split(size, "x")
width, _ := strconv.Atoi(parts[0])
height, _ := strconv.Atoi(parts[1])
result = append(result, ImageSizePreset{Tier: tier.name, Ratio: ratios[index], Width: width, Height: height, Size: size})
}
}
return result
}

View File

@@ -0,0 +1,23 @@
package app
import "testing"
func TestKemeiSeedreamDefaultsAreValidAndUseOfficialTiers(t *testing.T) {
for _, name := range []string{"doubao-seedream-5-0-pro-260628", "doubao-seedream-5-0-flash-260915"} {
profile := DefaultModelCapabilityConfigForModel("km-kemei-seedream", name).Image
if err := validateImageCapabilityConfig(profile); err != nil {
t.Fatal(err)
}
if profile.References.MaxImages != 10 || profile.References.MaskSupported || profile.MaxOutputs != 1 || profile.Size.Default != "2048x2048" || len(profile.Size.Presets) != 24 || profile.Quality.Supported || profile.TransparentBackground.Supported {
t.Fatalf("defaults: %#v", profile)
}
for _, preset := range profile.Size.Presets {
if preset.Tier == "4k" {
t.Fatal("unsupported 4K default")
}
if preset.Size == "2816x1584" && preset.Tier != "2k" {
t.Fatal("official 2K must not become 4K")
}
}
}
}

View File

@@ -65,8 +65,8 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error {
}
seenPresets := make(map[string]bool)
for _, preset := range value.Size.Presets {
if preset.Tier != "1k" && preset.Tier != "2k" && preset.Tier != "4k" {
return BadAuthRequest("图片分辨率档位仅支持 1K、2K、4K")
if preset.Tier != "1k" && preset.Tier != "1.5k" && preset.Tier != "2k" && preset.Tier != "4k" {
return BadAuthRequest("图片分辨率档位仅支持 1K、1.5K、2K、4K")
}
parts := strings.Split(preset.Ratio, ":")
if len(parts) != 2 {
@@ -416,6 +416,8 @@ func imageResolutionTier(quality string) string {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "1k", "low":
return "1k"
case "1.5k":
return "1.5k"
case "2k", "medium":
return "2k"
case "4k", "high":

View File

@@ -0,0 +1,105 @@
package protocol
import (
"context"
"testing"
)
func TestKemeiSeedreamRequestDefaultsAndReferences(t *testing.T) {
adapter := officialPackageAdapter(t, "km-kemei-seedream.yingce-plugin", "km-kemei-seedream")
for _, name := range []string{"doubao-seedream-5-0-pro-260628", "doubao-seedream-5-0-flash-260915"} {
request := GenerationRequest{Model: name, Prompt: "暖色书房中的橘猫", ImageCount: 1, Quality: "high"}
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request})
if err != nil {
t.Fatal(err)
}
body := manifestTestBody(t, spec)
if spec.Path != "/v1/images/generations" || body["model"] != name || body["n"] != float64(1) || body["size"] != "2K" || body["response_format"] != "url" || body["output_format"] != "jpeg" || body["watermark"] != false {
t.Fatalf("defaults = %#v", body)
}
for _, key := range []string{"image", "quality", "resolution", "seed", "stream", "web_search", "sequential_image_generation"} {
if _, exists := body[key]; exists {
t.Fatalf("unexpected %s: %#v", key, body)
}
}
request.Images = []MediaReference{{URL: "https://example.com/b.png", Order: 2}, {DataURL: "data:image/png;base64,aA==", Order: 1}}
request.AspectRatio = "16:9"
spec, err = adapter.BuildCreate(context.Background(), RequestContext{Request: request})
if err != nil {
t.Fatal(err)
}
body = manifestTestBody(t, spec)
images := body["image"].([]any)
if body["size"] != "2816x1584" || len(images) != 2 || images[0] != request.Images[1].DataURL {
t.Fatalf("references = %#v", body)
}
for _, size := range []string{"1K", "1.5K", "2K", "2352x1008", "2816x1584", "1536x1536"} {
request.AspectRatio = size
if _, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request}); err != nil {
t.Fatalf("size %s: %v", size, err)
}
}
}
}
func TestKemeiSeedreamRejectsUnsupportedAndOutOfRangeRequests(t *testing.T) {
adapter := officialPackageAdapter(t, "km-kemei-seedream.yingce-plugin", "km-kemei-seedream")
base := GenerationRequest{Model: "doubao-seedream-5-0-pro-260628", Prompt: "橘猫", ImageCount: 1, AspectRatio: "2048x2048"}
for _, tc := range []struct {
name string
change func(*GenerationRequest)
}{
{"wrong model", func(r *GenerationRequest) { r.Model = "midjourney-1k" }},
{"multiple outputs", func(r *GenerationRequest) { r.ImageCount = 2 }},
{"empty prompt", func(r *GenerationRequest) { r.Prompt = " " }},
{"too small", func(r *GenerationRequest) { r.AspectRatio = "512x512" }},
{"4k", func(r *GenerationRequest) { r.AspectRatio = "4K" }},
{"too large", func(r *GenerationRequest) { r.AspectRatio = "2880x2880" }},
{"bad dimensions", func(r *GenerationRequest) { r.AspectRatio = "2048x2048x1" }},
{"mask", func(r *GenerationRequest) {
r.Images = []MediaReference{{URL: "https://example.com/a.png", Role: "mask"}}
}},
{"too many references", func(r *GenerationRequest) {
for i := 0; i < 11; i++ {
r.Images = append(r.Images, MediaReference{URL: "https://example.com/a.png"})
}
}},
{"oversize reference", func(r *GenerationRequest) {
r.Images = []MediaReference{{URL: "https://example.com/a.png", Metadata: map[string]any{"bytes": 30*1024*1024 + 1}}}
}},
{"unsupported format", func(r *GenerationRequest) {
r.ProviderOptions = map[string]map[string]any{"km-kemei-seedream": {"output_format": "webp"}}
}},
{"group generation", func(r *GenerationRequest) {
r.ProviderOptions = map[string]map[string]any{"km-kemei-seedream": {"sequential_image_generation": "auto"}}
}},
{"transparent text", func(r *GenerationRequest) {
r.ProviderOptions = map[string]map[string]any{"km-kemei-seedream": {"background": "transparent", "output_format": "png"}}
}},
} {
t.Run(tc.name, func(t *testing.T) {
request := base
tc.change(&request)
if _, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request}); err == nil {
t.Fatal("invalid request accepted")
}
})
}
}
func TestKemeiSeedreamLayerResultsAndErrors(t *testing.T) {
adapter := officialPackageAdapter(t, "km-kemei-seedream.yingce-plugin", "km-kemei-seedream")
request := GenerationRequest{Model: "doubao-seedream-5-0-flash-260915", AspectRatio: "auto", Images: []MediaReference{{URL: "https://example.com/source.png"}}, ProviderOptions: map[string]map[string]any{"km-kemei-seedream": {"layer_decomposition": true, "output_format": "png"}}}
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request})
if err != nil || manifestTestBody(t, spec)["size"] != "auto" {
t.Fatalf("layer request: %#v %v", spec, err)
}
result, err := adapter.ParseCreate(context.Background(), []byte(`{"data":[{"url":"https://example.com/base.png","z_index":0},{"b64_json":"aA==","z_index":1}],"usage":{"generated_images":2}}`))
if err != nil || result.Status != StatusSucceeded || result.Result == nil || len(result.Result.Images) != 2 {
t.Fatalf("results: %#v %v", result, err)
}
result, err = adapter.ParseCreate(context.Background(), []byte(`{"error":{"code":"invalid_size","message":"尺寸不合法"}}`))
if err != nil || result.Status != StatusFailed || result.Message != "尺寸不合法" {
t.Fatalf("error: %#v %v", result, err)
}
}

View File

@@ -5,6 +5,13 @@ description: 从产品功能快速定位主要代码入口
# 代码功能地图
## 可美 KM Seedream
- 声明式请求、响应与参数合同:`plugin-packages/km-kemei-seedream/manifest.json`、`docs/interface.md`;分发包:`plugin-packages/km-kemei-seedream.yingce-plugin`。
- 后端默认能力与预设:`backend/internal/app/model_capability_defaults.go`、`model_capability_kemei.go`;档位校验:`model_capability_validate.go`。
- 前端预设与选择:`web/src/lib/kemei-image-presets.ts`、`image-resolution-tiers.ts`、`image-size-presets.ts`、`model-capabilities.ts`;界面入口:`web/src/components/image-size-picker.tsx`、`image-size-presets-editor.tsx`。
- 协议回归:`backend/internal/protocol/km_kemei_seedream_test.go`;前后端能力与尺寸测试位于对应测试目录。
## 自定义更新公告
- 后台编辑:`web/src/pages/admin/settings/components/update-announcement-editor.tsx`;版本、排序与草稿校验:`web/src/lib/update-announcement.ts`。

View File

@@ -5,6 +5,10 @@ description: 信封、错误码与 OpenAPI
# HTTP API 合同
## KM 图片尺寸能力
图片能力 `size.presets[].tier` 在已有档位之外接受 `1.5k`;前端档位选择、请求归一化与服务端预设校验保持一致。`km-kemei-seedream` 默认 `2048x2048`、单次一张、最多十张参考图,提供二十四个精确尺寸。透明输出使用插件扩展字段,不开启宿主通用透明背景开关。模型能力仍保存在现有 JSON 字段,无数据库迁移或新端点。
## 注册邮箱等价检查
公开注册与邮箱绑定的唯一性检查将 `gmail.com` 和 `googlemail.com` 视为同一域,移除本地部分的点及 `+` 后缀。例如 `a.b+x@googlemail.com` 与 `ab@gmail.com` 不允许创建两个账号。其他域保留点和 `+`;现有域名白名单规则保持不变。

View File

@@ -5,6 +5,12 @@ description: 当前项目已实现的核心工作台能力与使用入口
# 功能清单
## 可美 KM Seedream 图片
`km-kemei-seedream` 声明式插件接入 Seedream 5.0 Pro / Flash 的文生图与参考图编辑,支持 URL / Base64 响应并由宿主统一保存图片。精确模型 ID 与扩展参数见插件包内 `docs/interface.md`。
协议默认 2K 方图、单张生成、最多十张参考图,提供 1K / 1.5K / 2K 共二十四个精确尺寸预设。后台能力编辑、画布选择与服务端校验同步支持 1.5K。蒙版与通用质量开关关闭;透明输出通过插件命名空间配置,并要求单张含 alpha 的参考图。供应商错误和空结果保留失败语义。
## 卡藏 Midjourney
画布接收一次任务返回的全部图片,四张结果自动组成收起的图片堆叠,可展开、选择主图和分别使用。每张图保留独立资源及素材绑定,保存、刷新和任务重复同步不会将后续图片覆盖为第一张。已有仅显示首图的成功 Midjourney 节点在重新打开画布时从原任务恢复遗漏结果,无需再次生成。

View File

@@ -5,6 +5,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## 可美 KM Seedream 图片
- 已实现:Pro / Flash 声明式协议、参考图与扩展参数、结果保存,以及前后端一致的 1K / 1.5K / 2K 尺寸预设。
- [ ] 用户验收协议选择后的默认能力、1.5K 档位与宽高比切换、真实图片生成及刷新后的素材显示。
- [ ] 在供应商环境验证多参考图、Base64 响应、透明输出、全部尺寸和扩展参数;协议测试不替代真实供应商验收。
## 复制生图节点后节点消失
- 已修复:副本不再继承原节点的 `generationEffectKeys`,避免普通保存把它误判为未确认的生成结果。图片内容、提示词和参考字段仍保留,原节点不受影响。

Binary file not shown.

View File

@@ -0,0 +1,9 @@
# KM 可美 Seedream 5.0
`yingce.plugin/v2` 声明式图片协议包。使用渠道凭证,宿主统一负责 HTTPS、SSRF 校验、超时与媒体持久化;不包含脚本运行时或密钥。
适配精确模型 ID:`doubao-seedream-5-0-pro-260628`、`doubao-seedream-5-0-flash-260915`。
选择协议时“能力与参数”默认设为:2K / 1:1(2048x2048)、单张生成、最多 10 张参考图、单图 30 MB、关闭蒙版、关闭通用 quality 和透明背景开关。1K / 1.5K / 2K 各有八种官方精确尺寸。响应默认 URL、输出 JPEG、关闭水印;所有返回图片及用量交给宿主消费。
详情见 [接口说明](docs/interface.md)。

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -2,12 +2,13 @@ import { useState } from "react";
import "./image-size-picker.css";
import { Input, Button } from "antd";
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
import { IMAGE_RESOLUTIONS, imagePresetForRatio, imagePresetValue, imageQualityForSelection, imageQualityForTier, imageResolutionUsesQuality, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
import { imageResolutionTiers, imagePresetForRatio, imagePresetValue, imageQualityForSelection, imageQualityForTier, imageResolutionUsesQuality, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
import { buildImageResolutionOptions, type ImageResolutionOption, type ImageResolutionTier } from "@/lib/image-resolution-tiers";
import { resolveImageRequestSize, validateImageSize } from "@/services/api/image-validation";
export function ImageSizePicker({ profile, size, quality, onChange }: { profile: ImageCapabilityConfig; size: string; quality?: string; onChange: (size: string, quality?: string) => void }) {
const IMAGE_RESOLUTIONS = imageResolutionTiers(profile);
const presets = imageSizePresets(profile).filter((preset) => imageTierAvailable(profile, preset.tier));
const visibleTiers = IMAGE_RESOLUTIONS.filter((value) => presets.some((preset) => preset.tier === value));
const [chosenTier, setChosenTier] = useState<ImageResolutionTier>("1k");

View File

@@ -4,10 +4,11 @@ import { Check, Plus } from "lucide-react";
import { Switch } from "@/components/ui/base/switch";
import "./image-size-picker.css";
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
import { IMAGE_RATIOS, imageResolutionTiers, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
import type { ImageResolutionTier } from "@/lib/image-resolution-tiers";
export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void }) {
const IMAGE_RESOLUTIONS = imageResolutionTiers(profile);
const presets = imageSizePresets(profile);
const id = useId();
const [drafts, setDrafts] = useState<Partial<Record<ImageResolutionTier, string>>>({});

View File

@@ -1,4 +1,4 @@
export type ImageResolutionTier = "1k" | "2k" | "4k";
export type ImageResolutionTier = "1k" | "1.5k" | "2k" | "4k";
export type ImageResolutionOption = {
size: string;
@@ -10,7 +10,7 @@ export type ImageResolutionOption = {
export type ImageResolutionChoice = "auto" | ImageResolutionTier;
const tierOrder: ImageResolutionTier[] = ["1k", "2k", "4k"];
const tierOrder: ImageResolutionTier[] = ["1k", "1.5k", "2k", "4k"];
const ratioOrder = ["1:1", "3:2", "2:3", "4:3", "3:4", "4:5", "5:4", "16:9", "9:16", "2:1", "1:2", "21:9"];
export function supportsImageResolutionPresets(size: { parameter: string; values: string[]; allowCustom?: boolean }) {

View File

@@ -2,6 +2,10 @@ import { buildImageResolutionOptions, type ImageResolutionOption, type ImageReso
import type { ImageCapabilityConfig } from "./model-capabilities";
export const IMAGE_RESOLUTIONS: ImageResolutionTier[] = ["1k", "2k", "4k"];
// 1.5K 只在模型明确配置此档位时出现,既有模型的档位保持原有顺序。
export function imageResolutionTiers(profile: ImageCapabilityConfig): ImageResolutionTier[] {
return profile.size.presets?.some((preset) => preset.tier === "1.5k") || profile.quality.values.includes("1.5k") ? ["1k", "1.5k", "2k", "4k"] : IMAGE_RESOLUTIONS;
}
export const IMAGE_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9"];
const standardSizes: Record<string, string[]> = {
"1:1": ["1024x1024", "2048x2048", "2880x2880"],
@@ -33,9 +37,9 @@ export function imagePresetForRatio(tier: ImageResolutionTier, input: string): I
return width * h === height * w;
}) || `${w}:${h}`;
const index = IMAGE_RESOLUTIONS.indexOf(tier);
if (index < 0) throw new Error("分辨率仅支持 1K、2K、4K");
const standard = standardSizes[ratio]?.[index];
const pixels = [1_048_576, 4_194_304, 8_294_400][index];
if (index < 0 && tier !== "1.5k") throw new Error("分辨率仅支持 1K、1.5K、2K、4K");
const standard = tier === "1.5k" ? undefined : standardSizes[ratio]?.[index];
const pixels = tier === "1.5k" ? 2_359_296 : [1_048_576, 4_194_304, 8_294_400][index];
let width = Math.round(Math.sqrt((pixels * w) / h) / 16) * 16;
let height = Math.round((width * h) / w / 16) * 16;
while (width * height > pixels || Math.max(width, height) > 3840) {
@@ -52,12 +56,12 @@ export function imagePresetForRatio(tier: ImageResolutionTier, input: string): I
}
export function imageQualityForTier(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
const aliases = { "1k": ["1k", "low"], "2k": ["2k", "medium"], "4k": ["4k", "high"] }[tier];
const aliases = { "1k": ["1k", "low"], "1.5k": ["1.5k"], "2k": ["2k", "medium"], "4k": ["4k", "high"] }[tier];
return profile.quality.supported ? profile.quality.values.find((value) => aliases.includes(value.toLowerCase())) : undefined;
}
export function imageResolutionUsesQuality(profile: ImageCapabilityConfig) {
return profile.size.parameter === "aspect_ratio" && IMAGE_RESOLUTIONS.some((tier) => imageQualityForTier(profile, tier));
return profile.size.parameter === "aspect_ratio" && imageResolutionTiers(profile).some((tier) => imageQualityForTier(profile, tier));
}
export function imageTierAvailable(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
@@ -82,7 +86,7 @@ export function imageSizePresets(profile: ImageCapabilityConfig): ImageResolutio
if (profile.size.parameter === "none") return [];
if (profile.size.presets) return profile.size.presets;
const pixels = buildImageResolutionOptions(profile.size.values);
const tiers = profile.size.parameter === "aspect_ratio" ? IMAGE_RESOLUTIONS.filter((tier) => imageQualityForTier(profile, tier)) : [];
const tiers = profile.size.parameter === "aspect_ratio" ? imageResolutionTiers(profile).filter((tier) => imageQualityForTier(profile, tier)) : [];
const ratios = tiers.flatMap((tier) =>
profile.size.values.flatMap((ratio) => {
try {

View File

@@ -0,0 +1,17 @@
import type { ImageResolutionOption, ImageResolutionTier } from "./image-resolution-tiers";
// Seedream 5.0 官方像素表;不能按通用像素阈值把 2K 横屏误分为 4K。
export function kemeiSeedreamPresets(): ImageResolutionOption[] {
const ratios = ["1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9"];
const tiers: [ImageResolutionTier, string[]][] = [
["1k", ["1024x1024", "1152x864", "864x1152", "1424x800", "800x1424", "1248x832", "832x1248", "1568x672"]],
["1.5k", ["1536x1536", "1792x1344", "1344x1792", "2048x1152", "1152x2048", "1872x1248", "1248x1872", "2352x1008"]],
["2k", ["2048x2048", "2368x1776", "1776x2368", "2816x1584", "1584x2816", "2496x1664", "1664x2496", "3136x1344"]],
];
return tiers.flatMap(([tier, sizes]) =>
sizes.map((size, index) => {
const [width, height] = size.split("x").map(Number);
return { tier, ratio: ratios[index], width, height, size };
}),
);
}

View File

@@ -1,6 +1,7 @@
import type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols";
import type { ImageResolutionOption, ImageResolutionTier } from "@/lib/image-resolution-tiers";
import { imagePresetForRatio } from "./image-size-presets";
import { kemeiSeedreamPresets } from "./kemei-image-presets";
import { type WorkflowVideoFieldLike, workflowImageCapabilityConfig, workflowVideoCapabilityConfig } from "./model-capabilities-workflow";
import { resolveWorkflowVideoScreenSpec } from "./video-screen-specs";
@@ -220,6 +221,19 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
outputFormat: { supported: true },
maxOutputs: 15,
};
if (protocol === "km-kemei-seedream") {
const presets = kemeiSeedreamPresets();
image.references = { ...image.references, maxImages: 10, maskSupported: false };
image.size = { parameter: "size", values: presets.map((preset) => preset.size), default: "2048x2048", allowCustom: true, presets };
image.quality = { supported: false, values: [], default: "auto" };
// 透明输出要求一张含 alpha 的参考图,宿主通用开关不能保证此条件。
// 此能力通过插件命名空间的 background + output_format 显式配置。
image.transparentBackground = { supported: false, default: false };
image.responseFormat = { supported: true };
image.outputFormat = { supported: true };
image.maxOutputs = 1;
return image;
}
if (protocol === "kacang-midjourney-special" || protocol === "kacang-midjourney-v7" || protocol === "kacang-midjourney") {
const stable = protocol === "kacang-midjourney";
const extendedRatios = stable || protocol === "kacang-midjourney-v7";
@@ -493,7 +507,7 @@ export function normalizeImageValue(profile: ImageCapabilityConfig, value: { siz
: value.quality && profile.quality.values.includes(value.quality)
? value.quality
: profile.quality.default || "auto"
: requestedQuality === "1k" || requestedQuality === "2k" || requestedQuality === "4k"
: requestedQuality === "1k" || requestedQuality === "1.5k" || requestedQuality === "2k" || requestedQuality === "4k"
? requestedQuality
: presetTier || profile.quality.default || "auto";
const count = String(Math.max(1, Math.min(profile.maxOutputs, Math.floor(Math.abs(Number(value.count)) || 1))));

View File

@@ -1,15 +1,10 @@
import { http } from "@/services/api/request";
import type { ModelTag } from "@/lib/model-tags";
import type { ImageResolutionOption } from "@/lib/image-resolution-tiers";
export type InputConstraint = { min: number; max: number };
export type OptionConstraint = { values?: unknown[]; min?: number; max?: number; step?: number };
export type CapabilityImageSizePreset = {
size: string;
tier: "1k" | "2k" | "4k";
ratio: string;
width: number;
height: number;
};
export type CapabilityImageSizePreset = ImageResolutionOption;
export type CapabilityImageSize = {
parameter?: "size" | "aspect_ratio";
allowCustom?: boolean;

View File

@@ -9,6 +9,13 @@ const protocols = [definition("disabled-text", "text", false), definition("text"
const protocolsWithAudio = [...protocols, definition("audio", "audio")];
describe("channel model editor drafts", () => {
test("Kemei Seedream selection initializes the image profile and preserves pricing", () => {
const draft = initialChannelModelValues(null, protocols);
draft.priceTiers[0].unitPrice = 19;
const next = changeChannelModelCapability({ ...draft, capability: "image", protocol: "km-kemei-seedream", modelKey: "doubao-seedream-5-0-pro-260628" }, [definition("km-kemei-seedream", "image")]);
expect(next.capabilityConfig?.image).toMatchObject({ references: { maxImages: 10, maskSupported: false }, maxOutputs: 1, size: { default: "2048x2048" } });
expect(next.priceTiers[0].unitPrice).toBe(19);
});
test("new drafts select an enabled protocol and never share price state", () => {
const first = initialChannelModelValues(null, protocols);
first.priceTiers[0].unitPrice = 42;

View File

@@ -11,6 +11,19 @@ import { resolveImageRequestSize, validateImageSize } from "../src/services/api/
import { buildGeminiImageGenerationConfig } from "../src/lib/gemini-image";
describe("统一图片分辨率与宽高比", () => {
test("Seedream 5.0 shows 1.5K and retains the exact official 2K wide size", () => {
const profile = defaultImageCapabilityConfig("km-kemei-seedream", "doubao-seedream-5-0-pro-260628");
const intermediate = renderToStaticMarkup(<ImageSizePicker profile={profile} size="1536x1536" onChange={() => {}} />);
expect(intermediate).toMatch(/aria-pressed="true"[^>]*>1\.5K/);
expect(intermediate).toContain("1536 × 1536 px");
expect(intermediate).not.toMatch(/<button[^>]*>4K/);
const wide = renderToStaticMarkup(<ImageSizePicker profile={profile} size="2816x1584" onChange={() => {}} />);
expect(wide).toMatch(/aria-pressed="true"[^>]*>2K/);
expect(wide).toContain("2816 × 1584 px");
const reopened = normalizeModelCapabilityConfig({ version: 1, image: profile });
expect(reopened.image?.size.presets?.filter((p) => p.tier === "1.5k")).toHaveLength(8);
expect(resolveImageRequestSize(profile, "auto", "1536x1536")?.value).toBe("1536x1536");
});
test("管理员档位开关按已有比例启用,部分比例不显示为关闭", () => {
for (const model of ["midjourney-1k", "midjourney-2k"]) {
const profile = defaultImageCapabilityConfig("cangyuan-midjourney-v82", model);

View File

@@ -3,7 +3,23 @@ import test from "node:test";
// Bun 直接执行 TypeScript 测试时需要保留扩展名;生产 tsconfig 不包含 test/。
import { DEFAULT_VIDEO_PROMPT_MAX_CHARS, defaultModelCapabilityConfig, normalizeImageValue, normalizeVideoValue } from "../src/lib/model-capabilities.ts";
import { imageTierAvailable } from "../src/lib/image-size-presets.ts";
import { imageTierAvailable, imageResolutionTiers } from "../src/lib/image-size-presets.ts";
test("Kemei Seedream defaults retain official 1K/1.5K/2K sizes without a 4K preset", () => {
for (const model of ["doubao-seedream-5-0-pro-260628", "doubao-seedream-5-0-flash-260915"]) {
const image = defaultModelCapabilityConfig("km-kemei-seedream", model).image!;
assert.equal(image.references.maxImages, 10);
assert.equal(image.references.maskSupported, false);
assert.equal(image.maxOutputs, 1);
assert.equal(image.size.default, "2048x2048");
assert.equal(image.quality.supported, false);
assert.equal(image.size.presets!.length, 24);
assert.deepEqual([...new Set(image.size.presets!.map((p) => p.tier))], ["1k", "1.5k", "2k"]);
assert.equal(image.size.presets!.find((p) => p.size === "2816x1584")?.tier, "2k");
assert.ok(imageResolutionTiers(image).includes("1.5k"));
}
assert.deepEqual(imageResolutionTiers(defaultModelCapabilityConfig("openai-image").image!), ["1k", "2k", "4k"]);
});
test("text multimodal capability is not guessed from a model name", () => {
for (const model of ["gpt-4o", "gemini-2.5-pro", "doubao-seed"]) {