From 41a95399af6dc6b2015c27e9c5fd2e5aa5936ccf Mon Sep 17 00:00:00 2001 From: Hao OUYANG Date: Wed, 22 Jul 2026 23:17:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(media):=20xAI=20=E8=A7=86=E9=A2=91=20-=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=AE=98=E6=96=B9=E7=94=9F=E6=88=90=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/provider.go | 95 +++++++++++++++++++++++ backend/internal/service/provider_test.go | 54 +++++++++++++ 2 files changed, 149 insertions(+) diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 544a25a52..1bd99c47f 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -993,6 +993,10 @@ func validateGenerationInterface(mode string, interfaceType string) error { } func grokVideoBody(input canvasGenerationInput) (map[string]interface{}, error) { + if input.Config.InterfaceType == "xai-video" { + return xaiVideoBody(input) + } + seconds := defaultString(input.Config.VideoSeconds, "6") duration, err := strconv.Atoi(seconds) if err != nil || duration <= 0 { @@ -1022,6 +1026,29 @@ func grokVideoBody(input canvasGenerationInput) (map[string]interface{}, error) return body, nil } +// xAI 生成接口与 legacy /videos 使用不同字段,保持独立可避免兼容字段触发上游 422。 +func xaiVideoBody(input canvasGenerationInput) (map[string]interface{}, error) { + body := map[string]interface{}{ + "model": input.Config.Model, + "prompt": strings.TrimSpace(input.Prompt), + "duration": normalizeXAIVideoDuration(input.Config.VideoSeconds), + "aspect_ratio": normalizeXAIVideoAspectRatio(input.Config.Size), + "resolution": normalizeXAIVideoResolution(input.Config.VQuality), + } + if !shouldSendNewAPIVideoImages(input) || len(input.ReferenceImages) == 0 { + return body, nil + } + if len(input.ReferenceImages) > 1 { + return nil, fmt.Errorf("xAI 图生视频只支持 1 张起始图,当前连接了 %d 张", len(input.ReferenceImages)) + } + imageURL, err := openAIImageInputURL(input.ReferenceImages[0]) + if err != nil { + return nil, err + } + body["image"] = map[string]interface{}{"url": imageURL} + return body, nil +} + func runSeedanceVideosTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { id := resumedProviderRequestID(ctx) var created map[string]interface{} @@ -1787,6 +1814,74 @@ func normalizeVideoResolution(value string) string { return value + "p" } +func normalizeXAIVideoDuration(value string) int { + duration, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || duration <= 0 { + return 6 + } + if duration > 15 { + return 15 + } + return duration +} + +func normalizeXAIVideoResolution(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "480", "480p", "low": + return "480p" + case "1080", "1080p": + return "1080p" + default: + return "720p" + } +} + +func normalizeXAIVideoAspectRatio(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + allowed := map[string]bool{ + "1:1": true, "16:9": true, "9:16": true, "4:3": true, + "3:4": true, "3:2": true, "2:3": true, + } + if allowed[value] { + return value + } + parts := strings.Split(value, "x") + if len(parts) != 2 { + return "16:9" + } + width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) + height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return "16:9" + } + ratio := float64(width) / float64(height) + candidates := []struct { + name string + ratio float64 + }{ + {name: "1:1", ratio: 1}, + {name: "16:9", ratio: 16.0 / 9}, + {name: "9:16", ratio: 9.0 / 16}, + {name: "4:3", ratio: 4.0 / 3}, + {name: "3:4", ratio: 3.0 / 4}, + {name: "3:2", ratio: 3.0 / 2}, + {name: "2:3", ratio: 2.0 / 3}, + } + bestName := "16:9" + bestDifference := 2.0 + for _, candidate := range candidates { + difference := ratio - candidate.ratio + if difference < 0 { + difference = -difference + } + if difference < bestDifference { + bestName = candidate.name + bestDifference = difference + } + } + return bestName +} + func normalizeSeedanceDuration(value string) int { if strings.TrimSpace(value) == "-1" { return -1 diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 6e7f57813..9b30b6668 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -387,6 +387,14 @@ func TestRunVideoTaskUsesXAIVideoGenerationEndpoint(t *testing.T) { if body["model"] != "grok-imagine-video-1.5" || body["prompt"] != "make it move" { t.Errorf("request body = %#v", body) } + if body["duration"] != float64(10) || body["aspect_ratio"] != "1:1" || body["resolution"] != "720p" { + t.Errorf("xAI settings = %#v", body) + } + for _, legacyField := range []string{"seconds", "size", "images"} { + if _, exists := body[legacyField]; exists { + t.Errorf("request body includes legacy field %q: %#v", legacyField, body) + } + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"request_id":"video-1"}`)) case "GET /v1/videos/video-1": @@ -412,6 +420,8 @@ func TestRunVideoTaskUsesXAIVideoGenerationEndpoint(t *testing.T) { Model: "grok-imagine-video-1.5", InterfaceType: "xai-video", VideoSeconds: "10", + Size: "1:1", + VQuality: "720", }, }) if err != nil { @@ -427,6 +437,50 @@ func TestRunVideoTaskUsesXAIVideoGenerationEndpoint(t *testing.T) { } } +func TestXAIVideoBodyUsesOfficialImageShapeAndNormalizesSettings(t *testing.T) { + body, err := grokVideoBody(canvasGenerationInput{ + Prompt: "make it move", + Config: providerConfig{ + Model: "grok-imagine-video-1.5", + InterfaceType: "xai-video", + VideoSeconds: "20", + Size: "1024x1792", + VQuality: "1080", + }, + ReferenceImages: []providerMedia{{ID: "image-1", DataURL: testReferenceImageDataURL}}, + Metadata: map[string]interface{}{"videoEditOperation": "image_to_video"}, + }) + if err != nil { + t.Fatalf("grokVideoBody() error = %v", err) + } + if body["duration"] != 15 || body["aspect_ratio"] != "9:16" || body["resolution"] != "1080p" { + t.Fatalf("xAI settings = %#v", body) + } + image, ok := body["image"].(map[string]interface{}) + if !ok || image["url"] != testReferenceImageDataURL { + t.Fatalf("image = %#v", body["image"]) + } + for _, legacyField := range []string{"seconds", "size", "images"} { + if _, exists := body[legacyField]; exists { + t.Fatalf("body includes legacy field %q: %#v", legacyField, body) + } + } +} + +func TestXAIVideoBodyRejectsMultipleStartImages(t *testing.T) { + _, err := grokVideoBody(canvasGenerationInput{ + Config: providerConfig{Model: "grok-imagine-video-1.5", InterfaceType: "xai-video"}, + ReferenceImages: []providerMedia{ + {ID: "image-1", DataURL: testReferenceImageDataURL}, + {ID: "image-2", DataURL: testReferenceImageDataURL}, + }, + Metadata: map[string]interface{}{"videoEditOperation": "image_to_video"}, + }) + if err == nil || !strings.Contains(err.Error(), "只支持 1 张起始图") { + t.Fatalf("grokVideoBody() error = %v", err) + } +} + func TestNewAPIVideoPromptKeepsTextOnlyPromptUnchanged(t *testing.T) { input := canvasGenerationInput{ Prompt: "make it move",