From bfdabd38535b353ac400732a0ae1367f32d90414 Mon Sep 17 00:00:00 2001 From: wangbo Date: Fri, 31 Jul 2026 20:07:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(acceptance):=20=E6=A0=A1=E5=87=86=20Seedanc?= =?UTF-8?q?e=20=E5=9B=BE=E7=89=87=E8=BD=AC=E6=8D=A2=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 Volces 官方输入边界补齐 Seedance 2.0 候选能力,将错误的合法 4K 转换样本替换为真实越界图片,并让协议模拟器校验物化后的 Base64 data URL。\n\n验证:Go 全量测试、迁移安全检查、gofmt。 --- apps/api/cmd/acceptance-load/main.go | 2 +- apps/api/cmd/migrate/main_test.go | 22 +++++ .../api/internal/acceptanceemulator/server.go | 99 +++++++++++++++---- .../acceptanceemulator/server_test.go | 40 ++++++-- ...ces_seedance20_input_image_constraints.sql | 95 ++++++++++++++++++ docs/operations/production-acceptance.md | 6 +- 6 files changed, 233 insertions(+), 31 deletions(-) create mode 100644 apps/api/migrations/0098_volces_seedance20_input_image_constraints.sql diff --git a/apps/api/cmd/acceptance-load/main.go b/apps/api/cmd/acceptance-load/main.go index 969ee4b..045ee78 100644 --- a/apps/api/cmd/acceptance-load/main.go +++ b/apps/api/cmd/acceptance-load/main.go @@ -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 } diff --git a/apps/api/cmd/migrate/main_test.go b/apps/api/cmd/migrate/main_test.go index b64ee2f..7e66538 100644 --- a/apps/api/cmd/migrate/main_test.go +++ b/apps/api/cmd/migrate/main_test.go @@ -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 { diff --git a/apps/api/internal/acceptanceemulator/server.go b/apps/api/internal/acceptanceemulator/server.go index 65a898c..7ab0fa1 100644 --- a/apps/api/internal/acceptanceemulator/server.go +++ b/apps/api/internal/acceptanceemulator/server.go @@ -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 } diff --git a/apps/api/internal/acceptanceemulator/server_test.go b/apps/api/internal/acceptanceemulator/server_test.go index 9c9f4fc..84655a5 100644 --- a/apps/api/internal/acceptanceemulator/server_test.go +++ b/apps/api/internal/acceptanceemulator/server_test.go @@ -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}) diff --git a/apps/api/migrations/0098_volces_seedance20_input_image_constraints.sql b/apps/api/migrations/0098_volces_seedance20_input_image_constraints.sql new file mode 100644 index 0000000..9cdd05d --- /dev/null +++ b/apps/api/migrations/0098_volces_seedance20_input_image_constraints.sql @@ -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' + ) + ); diff --git a/docs/operations/production-acceptance.md b/docs/operations/production-acceptance.md index 21a145d..5f308fd 100644 --- a/docs/operations/production-acceptance.md +++ b/docs/operations/production-acceptance.md @@ -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,验证远程任务恢复、