feat(protocol): 可美 KM Seedream - 接入图片生成与编辑
This commit is contained in:
BIN
.github/pr-screenshots/km-seedream-size-presets.jpg
vendored
Normal file
BIN
.github/pr-screenshots/km-seedream-size-presets.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -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
|
||||
|
||||
29
backend/internal/app/model_capability_kemei.go
Normal file
29
backend/internal/app/model_capability_kemei.go
Normal 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
|
||||
}
|
||||
23
backend/internal/app/model_capability_kemei_test.go
Normal file
23
backend/internal/app/model_capability_kemei_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
105
backend/internal/protocol/km_kemei_seedream_test.go
Normal file
105
backend/internal/protocol/km_kemei_seedream_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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`。
|
||||
|
||||
@@ -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` 不允许创建两个账号。其他域保留点和 `+`;现有域名白名单规则保持不变。
|
||||
|
||||
@@ -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 节点在重新打开画布时从原任务恢复遗漏结果,无需再次生成。
|
||||
|
||||
@@ -5,6 +5,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 可美 KM Seedream 图片
|
||||
|
||||
- 已实现:Pro / Flash 声明式协议、参考图与扩展参数、结果保存,以及前后端一致的 1K / 1.5K / 2K 尺寸预设。
|
||||
- [ ] 用户验收协议选择后的默认能力、1.5K 档位与宽高比切换、真实图片生成及刷新后的素材显示。
|
||||
- [ ] 在供应商环境验证多参考图、Base64 响应、透明输出、全部尺寸和扩展参数;协议测试不替代真实供应商验收。
|
||||
|
||||
## 复制生图节点后节点消失
|
||||
|
||||
- 已修复:副本不再继承原节点的 `generationEffectKeys`,避免普通保存把它误判为未确认的生成结果。图片内容、提示词和参考字段仍保留,原节点不受影响。
|
||||
|
||||
BIN
plugin-packages/km-kemei-seedream.yingce-plugin
Normal file
BIN
plugin-packages/km-kemei-seedream.yingce-plugin
Normal file
Binary file not shown.
9
plugin-packages/km-kemei-seedream/README.md
Normal file
9
plugin-packages/km-kemei-seedream/README.md
Normal 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)。
|
||||
1525
plugin-packages/km-kemei-seedream/docs/interface.md
Normal file
1525
plugin-packages/km-kemei-seedream/docs/interface.md
Normal file
File diff suppressed because it is too large
Load Diff
1459
plugin-packages/km-kemei-seedream/manifest.json
Normal file
1459
plugin-packages/km-kemei-seedream/manifest.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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");
|
||||
|
||||
@@ -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>>>({});
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
17
web/src/lib/kemei-image-presets.ts
Normal file
17
web/src/lib/kemei-image-presets.ts
Normal 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 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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))));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
Reference in New Issue
Block a user