feat(seedance): 同步输入图片约束与自动转换
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func seedanceImageConstraintCandidate() store.RuntimeModelCandidate {
|
||||
return store.RuntimeModelCandidate{
|
||||
ModelType: "image_to_video",
|
||||
Capabilities: map[string]any{
|
||||
"image_to_video": map[string]any{
|
||||
"input_image_resolution_range": map[string]any{
|
||||
"min": map[string]any{"long_edge": float64(360), "short_edge": float64(360)},
|
||||
"max": map[string]any{"long_edge": float64(1920), "short_edge": float64(1080)},
|
||||
},
|
||||
"input_image_aspect_ratio_range": []any{float64(0.39), float64(2.5)},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pngImageDataURL(t *testing.T, width int, height int) string {
|
||||
t.Helper()
|
||||
source := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
source.Set(x, y, color.RGBA{R: 80, G: 120, B: 160, A: 255})
|
||||
}
|
||||
}
|
||||
var payload bytes.Buffer
|
||||
if err := png.Encode(&payload, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(payload.Bytes())
|
||||
}
|
||||
|
||||
func decodedImageSize(t *testing.T, source string) image.Point {
|
||||
t.Helper()
|
||||
if !strings.HasPrefix(source, "data:image/jpeg;base64,") {
|
||||
t.Fatalf("expected converted JPEG data URL, got prefix %.32q", source)
|
||||
}
|
||||
payload, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(source, "data:image/jpeg;base64,"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, err := jpeg.DecodeConfig(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return image.Pt(config.Width, config.Height)
|
||||
}
|
||||
|
||||
func TestNormalizeVideoInputImagesResizesOversizedSeedanceImage(t *testing.T) {
|
||||
service := &Service{}
|
||||
body := map[string]any{
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "image_url",
|
||||
"role": "first_frame",
|
||||
"image_url": map[string]any{"url": pngImageDataURL(t, 3840, 2160)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
normalized, err := service.normalizeVideoInputImages(context.Background(), body, seedanceImageConstraintCandidate())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := normalized["content"].([]any)
|
||||
item := content[0].(map[string]any)
|
||||
imageURL := item["image_url"].(map[string]any)["url"].(string)
|
||||
if got := decodedImageSize(t, imageURL); got != image.Pt(1920, 1080) {
|
||||
t.Fatalf("unexpected converted size: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVideoInputImagesPadsAspectRatioViolation(t *testing.T) {
|
||||
service := &Service{}
|
||||
source := pngImageDataURL(t, 1530, 500)
|
||||
body := map[string]any{"first_frame": source}
|
||||
|
||||
normalized, err := service.normalizeVideoInputImages(context.Background(), body, seedanceImageConstraintCandidate())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := decodedImageSize(t, normalized["first_frame"].(string)); got != image.Pt(1530, 612) {
|
||||
t.Fatalf("unexpected converted size: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVideoInputImagesUpscalesBelowMinimumResolution(t *testing.T) {
|
||||
service := &Service{}
|
||||
body := map[string]any{"first_frame": pngImageDataURL(t, 200, 100)}
|
||||
|
||||
normalized, err := service.normalizeVideoInputImages(context.Background(), body, seedanceImageConstraintCandidate())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := decodedImageSize(t, normalized["first_frame"].(string)); got != image.Pt(720, 360) {
|
||||
t.Fatalf("unexpected converted size: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVideoInputImagesKeepsCompliantImage(t *testing.T) {
|
||||
service := &Service{}
|
||||
source := pngImageDataURL(t, 1280, 720)
|
||||
body := map[string]any{"first_frame": source}
|
||||
|
||||
normalized, err := service.normalizeVideoInputImages(context.Background(), body, seedanceImageConstraintCandidate())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if normalized["first_frame"] != source {
|
||||
t.Fatal("compliant image should remain unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVideoInputImagesReturnsActionableDecodeError(t *testing.T) {
|
||||
service := &Service{}
|
||||
body := map[string]any{"first_frame": "data:image/png;base64,bm90LWltYWdl"}
|
||||
|
||||
_, err := service.normalizeVideoInputImages(context.Background(), body, seedanceImageConstraintCandidate())
|
||||
if err == nil {
|
||||
t.Fatal("expected decode error")
|
||||
}
|
||||
if clients.ErrorCode(err) != "invalid_parameter" || clients.ErrorParam(err) != "first_frame" {
|
||||
t.Fatalf("unexpected error contract: code=%s param=%s err=%v", clients.ErrorCode(err), clients.ErrorParam(err), err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user