fix(acceptance): 校准 Seedance 图片转换验收

按 Volces 官方输入边界补齐 Seedance 2.0 候选能力,将错误的合法 4K 转换样本替换为真实越界图片,并让协议模拟器校验物化后的 Base64 data URL。\n\n验证:Go 全量测试、迁移安全检查、gofmt。
This commit is contained in:
2026-07-31 20:07:28 +08:00
parent 2c7ed905a9
commit bfdabd3853
6 changed files with 233 additions and 31 deletions
+78 -21
View File
@@ -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})