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

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)
}
}