fix(acceptance): 校准 Seedance 图片转换验收
按 Volces 官方输入边界补齐 Seedance 2.0 候选能力,将错误的合法 4K 转换样本替换为真实越界图片,并让协议模拟器校验物化后的 Base64 data URL。\n\n验证:Go 全量测试、迁移安全检查、gofmt。
This commit is contained in:
@@ -848,7 +848,7 @@ func (o options) emulatorFixtureURLs() []string {
|
||||
out = append(out, fmt.Sprintf("%s/fixtures/image-%02d.webp", o.emulatorURL, index))
|
||||
}
|
||||
for index := 12; index < 16; index++ {
|
||||
out = append(out, fmt.Sprintf("%s/fixtures/image-%02d-4k.jpg", o.emulatorURL, index))
|
||||
out = append(out, fmt.Sprintf("%s/fixtures/image-%02d-oversized.jpg", o.emulatorURL, index))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -52,6 +52,28 @@ func TestSeedanceInputImageConstraintMigrationKeepsCatalogSnapshotsInSync(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolcesSeedanceInputImageConstraintMigrationUsesDocumentedBounds(t *testing.T) {
|
||||
payload, err := os.ReadFile("../../migrations/0098_volces_seedance20_input_image_constraints.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(payload)
|
||||
for _, required := range []string{
|
||||
"volces:doubao-seedance-2-0-260128",
|
||||
"volces:doubao-seedance-2-0-fast-260128",
|
||||
"volces:doubao-seedance-2-0-mini-260615",
|
||||
`"long_edge":300`,
|
||||
`"long_edge":6000`,
|
||||
`'[0.4,2.5]'::jsonb`,
|
||||
"'{metadata,rawModel,capabilities}'",
|
||||
"UPDATE platform_models",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("Volces Seedance input image migration is missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityEventSchemaMigrationsDefineCurrentLifecycle(t *testing.T) {
|
||||
streamPayload, err := os.ReadFile("../../migrations/0063_oidc_security_events.sql")
|
||||
if err != nil {
|
||||
|
||||
@@ -28,7 +28,10 @@ import (
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
const maxProtocolBodyBytes = 128 << 20
|
||||
const (
|
||||
maxProtocolBodyBytes = 128 << 20
|
||||
maxImageReferenceBytes = 32 << 20
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Now func() time.Time
|
||||
@@ -434,37 +437,30 @@ func (s *Server) validateVideoRequest(ctx context.Context, body map[string]any)
|
||||
}
|
||||
if forceConversion {
|
||||
for _, ref := range refs {
|
||||
if ref.Width >= 3840 || ref.Height >= 2160 {
|
||||
return nil, false, false, errors.New("forced 4K image did not pass through automatic normalization")
|
||||
if !seedanceImageWithinOfficialInputRange(ref.Width, ref.Height) {
|
||||
return nil, false, false, errors.New("forced oversized image did not pass through automatic normalization")
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs, longRun, forceConversion, nil
|
||||
}
|
||||
|
||||
func seedanceImageWithinOfficialInputRange(width int, height int) bool {
|
||||
if width < 300 || width > 6000 || height < 300 || height > 6000 {
|
||||
return false
|
||||
}
|
||||
ratio := float64(width) / float64(height)
|
||||
return ratio >= 0.4 && ratio <= 2.5
|
||||
}
|
||||
|
||||
func (s *Server) fetchImageReference(ctx context.Context, role string, rawURL string) (imageReference, error) {
|
||||
if rawURL == "" {
|
||||
return imageReference{}, errors.New("image reference URL is required")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
payload, err := s.readImageReference(ctx, rawURL)
|
||||
if err != nil {
|
||||
return imageReference{}, err
|
||||
}
|
||||
response, err := s.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return imageReference{}, fmt.Errorf("fetch image reference: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return imageReference{}, fmt.Errorf("fetch image reference: status %d", response.StatusCode)
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, 32<<20))
|
||||
if err != nil {
|
||||
return imageReference{}, err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return imageReference{}, errors.New("image reference is empty")
|
||||
}
|
||||
config, format, err := image.DecodeConfig(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return imageReference{}, fmt.Errorf("decode image reference: %w", err)
|
||||
@@ -476,6 +472,67 @@ func (s *Server) fetchImageReference(ctx context.Context, role string, rawURL st
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) readImageReference(ctx context.Context, rawURL string) ([]byte, error) {
|
||||
if strings.HasPrefix(strings.ToLower(rawURL), "data:") {
|
||||
return decodeImageDataURL(rawURL)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := s.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch image reference: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("fetch image reference: status %d", response.StatusCode)
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, maxImageReferenceBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil, errors.New("image reference is empty")
|
||||
}
|
||||
if len(payload) > maxImageReferenceBytes {
|
||||
return nil, errors.New("image reference exceeds 32 MiB")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func decodeImageDataURL(raw string) ([]byte, error) {
|
||||
header, encoded, ok := strings.Cut(raw, ",")
|
||||
if !ok || len(header) <= len("data:") || encoded == "" {
|
||||
return nil, errors.New("image data URL is malformed")
|
||||
}
|
||||
metadata := strings.Split(header[len("data:"):], ";")
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(metadata[0])), "image/") {
|
||||
return nil, errors.New("image data URL must declare an image media type")
|
||||
}
|
||||
base64Encoded := false
|
||||
for _, item := range metadata[1:] {
|
||||
if strings.EqualFold(strings.TrimSpace(item), "base64") {
|
||||
base64Encoded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !base64Encoded {
|
||||
return nil, errors.New("image data URL must use base64 encoding")
|
||||
}
|
||||
if base64.StdEncoding.DecodedLen(len(encoded)) > maxImageReferenceBytes {
|
||||
return nil, errors.New("image reference exceeds 32 MiB")
|
||||
}
|
||||
payload, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, errors.New("image data URL contains invalid base64")
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil, errors.New("image reference is empty")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func validateGeminiImageRequest(body map[string]any) (int, error) {
|
||||
generationConfig, _ := body["generationConfig"].(map[string]any)
|
||||
modalities, _ := generationConfig["responseModalities"].([]any)
|
||||
@@ -662,10 +719,10 @@ func buildFixtures() map[string]fixture {
|
||||
fixtures[fmt.Sprintf("image-%02d.webp", index+8)] = fixture{ContentType: "image/webp", Payload: payload}
|
||||
}
|
||||
for index := 0; index < 4; index++ {
|
||||
imageValue := patternedImage(4096, 2160, index+21)
|
||||
imageValue := patternedImage(6144, 2160, index+21)
|
||||
var encoded bytes.Buffer
|
||||
_ = jpeg.Encode(&encoded, imageValue, &jpeg.Options{Quality: 90})
|
||||
fixtures[fmt.Sprintf("image-%02d-4k.jpg", index+12)] = fixture{ContentType: "image/jpeg", Payload: encoded.Bytes()}
|
||||
fixtures[fmt.Sprintf("image-%02d-oversized.jpg", index+12)] = fixture{ContentType: "image/jpeg", Payload: encoded.Bytes()}
|
||||
}
|
||||
return fixtures
|
||||
}
|
||||
|
||||
@@ -58,14 +58,14 @@ func TestGeminiAndVolcesProtocolEmulation(t *testing.T) {
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
|
||||
fixtureResponse, err := http.Get(httpServer.URL + "/fixtures/image-12-4k.jpg")
|
||||
fixtureResponse, err := http.Get(httpServer.URL + "/fixtures/image-12-oversized.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("get 4K fixture: %v", err)
|
||||
t.Fatalf("get oversized fixture: %v", err)
|
||||
}
|
||||
config, format, err := image.DecodeConfig(fixtureResponse.Body)
|
||||
_ = fixtureResponse.Body.Close()
|
||||
if err != nil || format != "jpeg" || config.Width != 4096 || config.Height != 2160 {
|
||||
t.Fatalf("4K fixture format=%s config=%+v err=%v", format, config, err)
|
||||
if err != nil || format != "jpeg" || config.Width != 6144 || config.Height != 2160 {
|
||||
t.Fatalf("oversized fixture format=%s config=%+v err=%v", format, config, err)
|
||||
}
|
||||
|
||||
content := []any{map[string]any{"type": "text", "text": "video"}}
|
||||
@@ -125,6 +125,29 @@ func TestGeminiAndVolcesProtocolEmulation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcedConversionRejectsImagesOutsideOfficialSeedanceRange(t *testing.T) {
|
||||
server := New(Config{Wait: func(context.Context, time.Duration) error { return nil }})
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
|
||||
content := []any{map[string]any{"type": "text", "text": "acceptance-force-conversion"}}
|
||||
for _, name := range []string{"image-12-oversized.jpg", "image-00.png", "image-04.jpg"} {
|
||||
content = append(content, map[string]any{
|
||||
"type": "image_url", "role": "reference_image",
|
||||
"image_url": map[string]any{"url": httpServer.URL + "/fixtures/" + name},
|
||||
})
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"model": "seedance-test", "content": content})
|
||||
response, err := postIdempotent(httpServer.URL+"/contents/generations/tasks", body, "oversized")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d, want %d", response.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaddedPNGIsValidAndExact(t *testing.T) {
|
||||
for _, size := range []int{256 << 10, 4 << 20, 8 << 20} {
|
||||
payload := paddedPNG(size)
|
||||
@@ -148,6 +171,7 @@ func TestVolcesProtocolAcceptsThreeSixAndNineReferenceImages(t *testing.T) {
|
||||
server := New(Config{Wait: func(context.Context, time.Duration) error { return nil }})
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
dataImage := "data:image/png;base64," + base64.StdEncoding.EncodeToString(paddedPNG(0))
|
||||
|
||||
for _, imageCount := range []int{3, 6, 9} {
|
||||
content := []any{map[string]any{"type": "text", "text": "multi-reference video"}}
|
||||
@@ -159,11 +183,13 @@ func TestVolcesProtocolAcceptsThreeSixAndNineReferenceImages(t *testing.T) {
|
||||
if imageCount > 3 && index == imageCount-1 {
|
||||
role = "last_frame"
|
||||
}
|
||||
imageURL := fmt.Sprintf("%s/fixtures/image-%02d.png", httpServer.URL, index%4)
|
||||
if index%2 == 0 {
|
||||
imageURL = dataImage
|
||||
}
|
||||
content = append(content, map[string]any{
|
||||
"type": "image_url", "role": role,
|
||||
"image_url": map[string]any{
|
||||
"url": fmt.Sprintf("%s/fixtures/image-%02d.png", httpServer.URL, index%4),
|
||||
},
|
||||
"image_url": map[string]any{"url": imageURL},
|
||||
})
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"model": "seedance-test", "content": content, "seed": imageCount})
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Volces documents Seedance 2.0 image inputs as 300..6000 px on each edge
|
||||
-- with a width/height ratio of 0.4..2.5. The runtime consumes these
|
||||
-- capability fields to normalize only genuinely out-of-range references.
|
||||
WITH input_constraints AS (
|
||||
SELECT jsonb_build_object(
|
||||
'input_image_resolution_range',
|
||||
'{"min":{"long_edge":300,"short_edge":300},"max":{"long_edge":6000,"short_edge":6000}}'::jsonb,
|
||||
'input_image_aspect_ratio_range',
|
||||
'[0.4,2.5]'::jsonb
|
||||
) AS value
|
||||
),
|
||||
target_base_models AS (
|
||||
SELECT
|
||||
base_model.id,
|
||||
COALESCE(base_model.capabilities, '{}'::jsonb) || jsonb_build_object(
|
||||
'image_to_video',
|
||||
COALESCE(base_model.capabilities->'image_to_video', '{}'::jsonb) || input_constraints.value,
|
||||
'omni_video',
|
||||
COALESCE(base_model.capabilities->'omni_video', '{}'::jsonb) || input_constraints.value
|
||||
) AS image_capabilities
|
||||
FROM base_model_catalog base_model
|
||||
CROSS JOIN input_constraints
|
||||
WHERE base_model.provider_key = 'volces'
|
||||
AND (
|
||||
base_model.canonical_model_key IN (
|
||||
'volces:doubao-seedance-2-0-260128',
|
||||
'volces:doubao-seedance-2-0-fast-260128',
|
||||
'volces:doubao-seedance-2-0-mini-260615'
|
||||
)
|
||||
OR base_model.provider_model_name IN (
|
||||
'doubao-seedance-2-0-260128',
|
||||
'doubao-seedance-2-0-fast-260128',
|
||||
'doubao-seedance-2-0-mini-260615'
|
||||
)
|
||||
)
|
||||
),
|
||||
updated_base_models AS (
|
||||
UPDATE base_model_catalog base_model
|
||||
SET capabilities = target.image_capabilities,
|
||||
metadata = jsonb_set(
|
||||
COALESCE(base_model.metadata, '{}'::jsonb) || jsonb_build_object(
|
||||
'rawModel',
|
||||
COALESCE(base_model.metadata->'rawModel', '{}'::jsonb)
|
||||
),
|
||||
'{rawModel,capabilities}',
|
||||
target.image_capabilities - 'originalTypes',
|
||||
true
|
||||
),
|
||||
default_snapshot = CASE
|
||||
WHEN COALESCE(base_model.default_snapshot, '{}'::jsonb) = '{}'::jsonb
|
||||
THEN base_model.default_snapshot
|
||||
ELSE jsonb_set(
|
||||
jsonb_set(
|
||||
base_model.default_snapshot || jsonb_build_object(
|
||||
'metadata',
|
||||
COALESCE(base_model.default_snapshot->'metadata', '{}'::jsonb) || jsonb_build_object(
|
||||
'rawModel',
|
||||
COALESCE(base_model.default_snapshot#>'{metadata,rawModel}', '{}'::jsonb)
|
||||
)
|
||||
),
|
||||
'{capabilities}',
|
||||
target.image_capabilities,
|
||||
true
|
||||
),
|
||||
'{metadata,rawModel,capabilities}',
|
||||
target.image_capabilities - 'originalTypes',
|
||||
true
|
||||
)
|
||||
END,
|
||||
updated_at = now()
|
||||
FROM target_base_models target
|
||||
WHERE base_model.id = target.id
|
||||
RETURNING base_model.id
|
||||
)
|
||||
UPDATE platform_models platform_model
|
||||
SET capabilities = COALESCE(platform_model.capabilities, '{}'::jsonb) || jsonb_build_object(
|
||||
'image_to_video',
|
||||
COALESCE(platform_model.capabilities->'image_to_video', '{}'::jsonb) || input_constraints.value,
|
||||
'omni_video',
|
||||
COALESCE(platform_model.capabilities->'omni_video', '{}'::jsonb) || input_constraints.value
|
||||
),
|
||||
updated_at = now()
|
||||
FROM integration_platforms platform
|
||||
CROSS JOIN input_constraints
|
||||
WHERE platform_model.platform_id = platform.id
|
||||
AND platform.deleted_at IS NULL
|
||||
AND platform.provider = 'volces'
|
||||
AND (
|
||||
platform_model.base_model_id IN (SELECT id FROM updated_base_models)
|
||||
OR COALESCE(NULLIF(platform_model.provider_model_name, ''), platform_model.model_name) IN (
|
||||
'doubao-seedance-2-0-260128',
|
||||
'doubao-seedance-2-0-fast-260128',
|
||||
'doubao-seedance-2-0-mini-260615'
|
||||
)
|
||||
);
|
||||
@@ -46,8 +46,10 @@ SHA-256,不集中保存响应:
|
||||
| `gemini-peak` | 32 | 8 MiB | 8 MiB | 15–30 秒 |
|
||||
|
||||
多参考图视频使用正式 `content` 结构,按 60%/30%/10% 分配 3/6/9 图,包含 JPEG、PNG、
|
||||
WebP 和 4K 图片。128 组输入组合避免只命中相同缓存;每四个任务至少一个携带 4K 图片并
|
||||
要求在到达上游前完成缩放或重编码。
|
||||
WebP、合法 4K 图片和越过 Seedance 2.0 官方输入边界的 `6144x2160` 图片。官方边界为
|
||||
宽高均在 `300..6000 px` 且宽高比在 `0.4..2.5`;4K 本身不应被误判为越界。128 组输入
|
||||
组合避免只命中相同缓存;每四个任务至少一个携带越界图片,并要求在到达上游前完成缩放、
|
||||
补边或重编码。
|
||||
|
||||
- `video-throughput`:1200 个任务并发提交,提交窗口不超过 10 秒。
|
||||
- `video-recovery`:96 个 2–3 分钟任务,运行中删除香港 Worker Pod,验证远程任务恢复、
|
||||
|
||||
Reference in New Issue
Block a user