package service import ( "bytes" "context" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "reflect" "strconv" "strings" "testing" ) const testReferenceImageDataURL = "data:image/png;base64,aGVsbG8=" func TestWriteMediaPartSanitizesFilenameAndSetsMimeType(t *testing.T) { var body bytes.Buffer writer := multipart.NewWriter(&body) if err := writeMediaPart(writer, "image", providerMedia{ID: "image-1", Name: "提示词\n带换行.png", Type: "image/png", DataURL: testReferenceImageDataURL}); err != nil { t.Fatalf("writeMediaPart() error = %v", err) } if err := writer.Close(); err != nil { t.Fatalf("multipart.Writer.Close() error = %v", err) } request := httptest.NewRequest(http.MethodPost, "http://example.test", bytes.NewReader(body.Bytes())) request.Header.Set("Content-Type", writer.FormDataContentType()) if err := request.ParseMultipartForm(1 << 20); err != nil { t.Fatalf("ParseMultipartForm() error = %v", err) } files := request.MultipartForm.File["image"] if len(files) != 1 { t.Fatalf("image files = %d, want 1", len(files)) } file := files[0] if file.Filename != "reference-image-1.png" || strings.ContainsAny(file.Filename, "\r\n") { t.Fatalf("filename = %q", file.Filename) } if got := file.Header.Get("Content-Type"); got != "image/png" { t.Fatalf("part Content-Type = %q, want image/png", got) } opened, err := file.Open() if err != nil { t.Fatalf("file.Open() error = %v", err) } defer opened.Close() data, err := io.ReadAll(opened) if err != nil { t.Fatalf("io.ReadAll() error = %v", err) } if string(data) != "hello" { t.Fatalf("file data = %q, want hello", data) } } func TestProviderHTTPErrorWarnsAboutUncertain524Billing(t *testing.T) { message := (providerHTTPError{StatusCode: 524, Status: "524 A Timeout Occurred"}).Error() if !strings.Contains(message, "可能仍在服务端执行并产生费用") || !strings.Contains(message, "请勿立即重试") { t.Fatalf("providerHTTPError.Error() = %q", message) } } func TestVolcengineArkImageBodyUsesJSONReferencesAndDownscalesSize(t *testing.T) { body, err := volcengineArkImageBody(canvasGenerationInput{ Prompt: "combine the references", Config: providerConfig{Model: "doubao-seedream-test", Size: "3840x2160", SystemPrompt: "keep the subject"}, ReferenceImages: []providerMedia{ {URL: "https://example.com/first.png"}, {DataURL: testReferenceImageDataURL}, }, }) if err != nil { t.Fatalf("volcengineArkImageBody() error = %v", err) } images, ok := body["image"].([]string) if !ok || len(images) != 2 || images[0] != "https://example.com/first.png" || images[1] != testReferenceImageDataURL { t.Fatalf("image = %#v", body["image"]) } if body["prompt"] != "keep the subject\n\ncombine the references" { t.Fatalf("prompt = %q", body["prompt"]) } size, _ := body["size"].(string) parts := strings.Split(size, "x") if len(parts) != 2 { t.Fatalf("size = %q", size) } width, _ := strconv.Atoi(parts[0]) height, _ := strconv.Atoi(parts[1]) if width%2 != 0 || height%2 != 0 || int64(width)*int64(height) > volcengineArkImageMaxPixels { t.Fatalf("downscaled size = %q", size) } } func TestVolcengineArkImageRejectsMaskBeforeRequest(t *testing.T) { _, err := runImageTask(context.Background(), canvasGenerationInput{ Prompt: "edit only the masked area", Config: providerConfig{InterfaceType: "volcengine-ark-image"}, Mask: &providerMedia{DataURL: testReferenceImageDataURL}, }) if err == nil || !strings.Contains(err.Error(), "不支持蒙版") { t.Fatalf("runImageTask() error = %v", err) } } func TestDoBinaryRejectsOversizedProviderResponse(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Length", strconv.FormatInt(maxProviderResponseBytes+1, 10)) w.WriteHeader(http.StatusOK) })) defer server.Close() _, _, err := getExternalBinary(context.Background(), server.URL) if err == nil || !strings.Contains(err.Error(), "超过 64MB") { t.Fatalf("getExternalBinary() error = %v", err) } } func TestTextResponseInputIncludesReferenceMedia(t *testing.T) { input := canvasGenerationInput{ Prompt: "describe this image", Config: providerConfig{SystemPrompt: "answer in Chinese"}, ReferenceImages: []providerMedia{ {ID: "image-1", Name: "image.png", Type: "image/png", DataURL: testReferenceImageDataURL}, }, ReferenceVideos: []providerMedia{ {ID: "video-1", Name: "video.mp4", Type: "video/mp4", URL: "https://example.com/reference.mp4"}, }, } value, err := textResponseInput(input) if err != nil { t.Fatalf("textResponseInput() error = %v", err) } messages, ok := value.([]map[string]interface{}) if !ok { t.Fatalf("textResponseInput() = %T, want []map[string]interface{}", value) } if len(messages) != 2 { t.Fatalf("len(messages) = %d, want 2", len(messages)) } if messages[0]["role"] != "system" || messages[0]["content"] != "answer in Chinese" { t.Fatalf("system message = %#v", messages[0]) } content, ok := messages[1]["content"].([]map[string]interface{}) if !ok { t.Fatalf("user content = %T, want []map[string]interface{}", messages[1]["content"]) } if len(content) != 3 { t.Fatalf("len(content) = %d, want 3", len(content)) } if content[0]["type"] != "input_text" || content[0]["text"] != "describe this image" { t.Fatalf("text content = %#v", content[0]) } if content[1]["type"] != "input_image" || content[1]["image_url"] != testReferenceImageDataURL { t.Fatalf("image content = %#v", content[1]) } if content[2]["type"] != "input_video" || content[2]["video_url"] != "https://example.com/reference.mp4" { t.Fatalf("video content = %#v", content[2]) } } func TestTextChatContentIncludesReferenceMedia(t *testing.T) { input := canvasGenerationInput{ Prompt: "describe this image", ReferenceImages: []providerMedia{ {ID: "image-1", Name: "image.png", Type: "image/png", DataURL: testReferenceImageDataURL}, }, ReferenceVideos: []providerMedia{ {ID: "video-1", Name: "video.mp4", Type: "video/mp4", URL: "https://example.com/reference.mp4"}, }, } value, err := textChatContent(input) if err != nil { t.Fatalf("textChatContent() error = %v", err) } content, ok := value.([]map[string]interface{}) if !ok { t.Fatalf("textChatContent() = %T, want []map[string]interface{}", value) } if len(content) != 3 { t.Fatalf("len(content) = %d, want 3", len(content)) } if content[0]["type"] != "text" || content[0]["text"] != "describe this image" { t.Fatalf("text content = %#v", content[0]) } imageURL, ok := content[1]["image_url"].(map[string]interface{}) if !ok { t.Fatalf("image_url = %T, want map[string]interface{}", content[1]["image_url"]) } if content[1]["type"] != "image_url" || imageURL["url"] != testReferenceImageDataURL { t.Fatalf("image content = %#v", content[1]) } videoURL, ok := content[2]["video_url"].(map[string]interface{}) if !ok { t.Fatalf("video_url = %T, want map[string]interface{}", content[2]["video_url"]) } if content[2]["type"] != "video_url" || videoURL["url"] != "https://example.com/reference.mp4" { t.Fatalf("video content = %#v", content[2]) } } func TestTextReferenceImageRejectsInternalAssetURL(t *testing.T) { _, err := textResponseInput(canvasGenerationInput{ Prompt: "describe this image", ReferenceImages: []providerMedia{ {ID: "image-1", Name: "image.png", URL: "asset://local-image"}, }, }) if err == nil { t.Fatal("textResponseInput() error = nil, want error") } } func TestSeedanceVideosBodyUsesVideosEndpointFields(t *testing.T) { body, err := seedanceVideosBody(canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{ Model: "seedance-2.0-mini-480p", Size: "9:16", VideoSeconds: "8", VideoGenerateAudio: "true", }, ReferenceImages: []providerMedia{ {ID: "image-1", DataURL: testReferenceImageDataURL}, {ID: "image-2", DataURL: "data:image/png;base64,d29ybGQ="}, }, ReferenceVideos: []providerMedia{{ID: "video-1", URL: "https://example.com/ref.mp4"}}, ReferenceAudios: []providerMedia{{ID: "audio-1", DataURL: "data:audio/mpeg;base64,AAAA"}}, }) if err != nil { t.Fatalf("seedanceVideosBody() error = %v", err) } if body["model"] != "seedance-2.0-mini-480p" { t.Fatalf("model = %#v", body["model"]) } if body["aspect_ratio"] != "9:16" || body["duration"] != 8 { t.Fatalf("size fields = %#v %#v", body["aspect_ratio"], body["duration"]) } if body["generate_audio"] != true { t.Fatalf("generate_audio = %#v, want true", body["generate_audio"]) } if body["image_url"] != testReferenceImageDataURL { t.Fatalf("image_url = %#v", body["image_url"]) } referenceImages, ok := body["reference_image_urls"].([]string) if !ok || len(referenceImages) != 1 || referenceImages[0] != "data:image/png;base64,d29ybGQ=" { t.Fatalf("reference_image_urls = %#v", body["reference_image_urls"]) } referenceVideos, ok := body["reference_videos"].([]string) if !ok || len(referenceVideos) != 1 || referenceVideos[0] != "https://example.com/ref.mp4" { t.Fatalf("reference_videos = %#v", body["reference_videos"]) } referenceAudios, ok := body["reference_audios"].([]string) if !ok || len(referenceAudios) != 1 || referenceAudios[0] != "data:audio/mpeg;base64,AAAA" { t.Fatalf("reference_audios = %#v", body["reference_audios"]) } if body["content"] != nil || body["ratio"] != nil { t.Fatalf("unexpected agent-plan fields in body: %#v", body) } } func TestSeedanceVideosBodyHonorsGenerateAudio(t *testing.T) { for _, test := range []struct { name string value string want bool }{ {name: "default enabled", want: true}, {name: "explicit enabled", value: "true", want: true}, {name: "explicit disabled", value: "false", want: false}, } { t.Run(test.name, func(t *testing.T) { body, err := seedanceVideosBody(canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{ Model: "seedance-2.0-mini-480p", VideoGenerateAudio: test.value, }, }) if err != nil { t.Fatalf("seedanceVideosBody() error = %v", err) } if body["generate_audio"] != test.want { t.Fatalf("generate_audio = %#v, want %v", body["generate_audio"], test.want) } }) } } func TestSeedanceVideosBodyUsesOrderedFrameImageURLsWhenConfigured(t *testing.T) { body, err := seedanceVideosBody(canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{Model: "seedance-2.0-mini-480p"}, ReferenceImages: []providerMedia{ {ID: "character", DataURL: "data:image/png;base64,Y2hhcmFjdGVy"}, {ID: "end-frame", DataURL: "data:image/png;base64,d29ybGQ="}, {ID: "front-frame", DataURL: testReferenceImageDataURL}, }, Metadata: map[string]interface{}{"videoStartFrameNodeId": "front-frame", "videoEndFrameNodeId": "end-frame"}, }) if err != nil { t.Fatalf("seedanceVideosBody() error = %v", err) } imageURLs, ok := body["image_urls"].([]string) if !ok || len(imageURLs) != 3 { t.Fatalf("image_urls = %#v", body["image_urls"]) } want := []string{testReferenceImageDataURL, "data:image/png;base64,d29ybGQ=", "data:image/png;base64,Y2hhcmFjdGVy"} for index := range want { if imageURLs[index] != want[index] { t.Fatalf("image_urls = %#v, want %#v", imageURLs, want) } } if body["image_url"] != nil || body["reference_image_urls"] != nil { t.Fatalf("unexpected legacy image fields in body: %#v", body) } if prompt := body["prompt"]; prompt != "make it move" { t.Fatalf("prompt = %#v", body["prompt"]) } } func TestRunVideoTaskUsesNewAPIForAnyVideoModel(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") paths := make([]string, 0, 3) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.Method+" "+r.URL.Path) switch r.Method + " " + r.URL.Path { case "POST /v1/videos": if err := r.ParseMultipartForm(1 << 20); err != nil { t.Errorf("parse create body: %v", err) } if r.FormValue("model") != "custom-video-v1" || r.FormValue("prompt") != "make it move" { t.Errorf("create form = %#v", r.MultipartForm.Value) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"video-1","status":"queued"}`)) case "GET /v1/videos/video-1": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"video-1","status":"completed"}`)) case "GET /v1/videos/video-1/content": w.Header().Set("Content-Type", "video/mp4") _, _ = w.Write([]byte("video")) default: http.NotFound(w, r) } })) defer server.Close() result, err := runVideoTask(context.Background(), canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "custom-video-v1"}, }) if err != nil { t.Fatalf("runVideoTask() error = %v", err) } video, ok := result["video"].(map[string]interface{}) if !ok || video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" { t.Fatalf("video = %#v", result["video"]) } want := "POST /v1/videos,GET /v1/videos/video-1,GET /v1/videos/video-1/content" if got := strings.Join(paths, ","); got != want { t.Fatalf("paths = %q, want %q", got, want) } } func TestRunVideoTaskUsesNestedURLBeforeResultURL(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") paths := make([]string, 0, 3) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.Method+" "+r.URL.Path) switch r.Method + " " + r.URL.Path { case "POST /v1/videos": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":{"task_id":"video-1","status":"queued"}}`)) case "GET /v1/videos/video-1": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"video-1","status":"SUCCESS","result_url":"` + server.URL + `/v1/videos/video-1/content","data":{"status":"completed","url":"` + server.URL + `/files/video.mp4"}}}`)) case "GET /files/video.mp4": if authorization := r.Header.Get("Authorization"); authorization != "" { t.Errorf("file Authorization = %q, want empty", authorization) } w.Header().Set("Content-Type", "video/mp4") _, _ = w.Write([]byte("video")) case "GET /v1/videos/video-1/content": http.Error(w, "forbidden", http.StatusForbidden) default: http.NotFound(w, r) } })) defer server.Close() result, err := runVideoTask(context.Background(), canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "grok-imagine-video-1.5-1080p", VideoSeconds: "15"}, }) if err != nil { t.Fatalf("runVideoTask() error = %v", err) } video, ok := result["video"].(map[string]interface{}) if !ok || video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" { t.Fatalf("video = %#v", result["video"]) } want := "POST /v1/videos,GET /v1/videos/video-1,GET /files/video.mp4" if got := strings.Join(paths, ","); got != want { t.Fatalf("paths = %q, want %q", got, want) } } func TestRunVideoTaskUsesJSONForGrokVideo(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method + " " + r.URL.Path { case "POST /v1/videos": if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") { t.Errorf("Content-Type = %q, want application/json", contentType) } var body map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode request: %v", err) } if body["model"] != "grok-video" || body["prompt"] != "make it move" { t.Errorf("request body = %#v", body) } if body["image"] != testReferenceImageDataURL { t.Errorf("image = %#v", body["image"]) } images, ok := body["images"].([]interface{}) if !ok || len(images) != 1 || images[0] != testReferenceImageDataURL { t.Errorf("images = %#v", body["images"]) } _, _ = w.Write([]byte(`{"id":"video-1","status":"queued"}`)) case "GET /v1/videos/video-1": _, _ = w.Write([]byte(`{"id":"video-1","status":"completed"}`)) case "GET /v1/videos/video-1/content": w.Header().Set("Content-Type", "video/mp4") _, _ = w.Write([]byte("video")) default: http.NotFound(w, r) } })) defer server.Close() result, err := runVideoTask(context.Background(), canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "grok-video", VideoSeconds: "10"}, ReferenceImages: []providerMedia{{ID: "image-1", DataURL: testReferenceImageDataURL}}, Metadata: map[string]interface{}{"videoEditOperation": "image_to_video"}, }) if err != nil { t.Fatalf("runVideoTask() error = %v", err) } video, ok := result["video"].(map[string]interface{}) if !ok || video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" { t.Fatalf("video = %#v", result["video"]) } } func TestRunVideoTaskUsesXAIVideoGenerationEndpoint(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") paths := make([]string, 0, 3) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.Method+" "+r.URL.Path) switch r.Method + " " + r.URL.Path { case "POST /v1/videos/generations": if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") { t.Errorf("Content-Type = %q, want application/json", contentType) } var body map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode request: %v", err) } 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": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"done","video":{"url":"` + server.URL + `/files/video.mp4"}}`)) case "GET /files/video.mp4": if authorization := r.Header.Get("Authorization"); authorization != "" { t.Errorf("file Authorization = %q, want empty", authorization) } w.Header().Set("Content-Type", "video/mp4") _, _ = w.Write([]byte("video")) default: http.NotFound(w, r) } })) defer server.Close() result, err := runVideoTask(context.Background(), canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{ BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "grok-imagine-video-1.5", InterfaceType: "xai-video", VideoSeconds: "10", Size: "1:1", VQuality: "720", }, }) if err != nil { t.Fatalf("runVideoTask() error = %v", err) } video, ok := result["video"].(map[string]interface{}) if !ok || video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" { t.Fatalf("video = %#v", result["video"]) } want := "POST /v1/videos/generations,GET /v1/videos/video-1,GET /files/video.mp4" if got := strings.Join(paths, ","); got != want { t.Fatalf("paths = %q, want %q", got, want) } } 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"] != 20 || 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", } if prompt := newAPIVideoPromptText(input); prompt != "make it move" { t.Fatalf("prompt = %q", prompt) } } func TestVideoProviderPromptsKeepReferencePromptUnchanged(t *testing.T) { input := canvasGenerationInput{ Prompt: "镜头缓慢前推,人物走向门口", ReferenceImages: []providerMedia{{ID: "image-1", DataURL: testReferenceImageDataURL}}, Metadata: map[string]interface{}{"videoEditOperation": "image_to_video"}, } for name, prompt := range map[string]string{ "newapi": newAPIVideoPromptText(input), "seedance-content": seedancePromptText(input), "seedance-videos": seedanceVideosPromptText(input), } { if prompt != input.Prompt { t.Fatalf("%s prompt = %q", name, prompt) } } } func TestNewAPIVideoOmitsImagesForTextToVideoOperation(t *testing.T) { input := canvasGenerationInput{ Prompt: "make it move with the described character", ReferenceImages: []providerMedia{ {ID: "image-1", DataURL: testReferenceImageDataURL}, }, Metadata: map[string]interface{}{"videoEditOperation": "text_to_video"}, } if shouldSendNewAPIVideoImages(input) { t.Fatal("shouldSendNewAPIVideoImages() = true, want false") } if prompt := newAPIVideoPromptText(input); strings.Contains(prompt, "@image1") { t.Fatalf("prompt = %q", prompt) } } func TestSeedanceVideosBodyRequiresImageForVideoOrAudioReferences(t *testing.T) { _, err := seedanceVideosBody(canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{Model: "seedance-2.0-mini-480p"}, ReferenceVideos: []providerMedia{{ID: "video-1", URL: "https://example.com/ref.mp4"}}, }) if err == nil { t.Fatal("seedanceVideosBody() error = nil, want error") } } func TestArkPlanConfigStaysSeparateFromSeedanceVideosEndpoint(t *testing.T) { config := providerConfig{BaseURL: "https://ark.cn-beijing.volces.com/api/plan/v3", Model: "seedance-2.0-pro"} if !isArkPlanVideoConfig(config) { t.Fatal("isArkPlanVideoConfig() = false, want true") } if !isSeedanceVideoConfig(config) { t.Fatal("isSeedanceVideoConfig() = false, want true") } } func TestVolcengineArkVideoProtocolUsesContentTaskAndDownloadsResult(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") paths := make([]string, 0, 3) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.Method+" "+r.URL.Path) switch r.Method + " " + r.URL.Path { case "POST /api/v3/contents/generations/tasks": var body map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode request: %v", err) } content, _ := body["content"].([]interface{}) if len(content) != 2 { t.Errorf("body = %#v", body) return } imageContent, _ := content[1].(map[string]interface{}) if body["model"] != "doubao-seedance-test" || imageContent["role"] != "reference_image" { t.Errorf("body = %#v", body) } _, _ = w.Write([]byte(`{"id":"ark-task-1","status":"running"}`)) case "GET /api/v3/contents/generations/tasks/ark-task-1": _, _ = w.Write([]byte(`{"id":"ark-task-1","status":"succeeded","content":{"video_url":"` + server.URL + `/result.mp4"}}`)) case "GET /result.mp4": w.Header().Set("Content-Type", "video/mp4") _, _ = w.Write([]byte("video")) default: http.NotFound(w, r) } })) defer server.Close() result, err := runVideoTask(context.Background(), canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{BaseURL: server.URL + "/api/v3", APIKey: "test-key", Model: "doubao-seedance-test", InterfaceType: "volcengine-ark-video"}, ReferenceImages: []providerMedia{{ID: "start", URL: server.URL + "/reference.png"}}, Metadata: map[string]interface{}{"videoStartFrameNodeId": "start"}, }) if err != nil { t.Fatalf("runVideoTask() error = %v", err) } video := result["video"].(map[string]interface{}) if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" { t.Fatalf("video = %#v", video) } want := "POST /api/v3/contents/generations/tasks,GET /api/v3/contents/generations/tasks/ark-task-1,GET /result.mp4" if got := strings.Join(paths, ","); got != want { t.Fatalf("paths = %q, want %q", got, want) } } func TestNewAPIChannel1VideoBodyMapsFramesAndReferences(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) defer server.Close() body, err := newAPIChannel1VideoBody(canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{Model: "seedance-2.0", Size: "9:16", VQuality: "1080", VideoSeconds: "15", VideoWatermark: "true"}, ReferenceImages: []providerMedia{ {ID: "first", URL: server.URL + "/first.png"}, {ID: "last", URL: server.URL + "/last.png"}, {ID: "character", URL: server.URL + "/character.png"}, }, ReferenceVideos: []providerMedia{{ID: "video", URL: server.URL + "/reference.mp4"}}, ReferenceAudios: []providerMedia{{ID: "voice", URL: server.URL + "/voice.mp3"}}, Metadata: map[string]interface{}{"videoStartFrameNodeId": "first", "videoEndFrameNodeId": "last"}, }) if err != nil { t.Fatalf("newAPIChannel1VideoBody() error = %v", err) } input := body["input"].(map[string]interface{}) media := input["media"].([]map[string]string) wantTypes := []string{"first_frame", "last_frame", "reference_image", "reference_video", "reference_voice"} if len(media) != len(wantTypes) { t.Fatalf("media = %#v", media) } for index, want := range wantTypes { if media[index]["type"] != want { t.Fatalf("media[%d].type = %q, want %q", index, media[index]["type"], want) } } parameters := body["parameters"].(map[string]interface{}) if parameters["resolution"] != "1080P" || parameters["ratio"] != "9:16" || parameters["duration"] != 15 || parameters["watermark"] != true { t.Fatalf("parameters = %#v", parameters) } } func TestNewAPIChannel1VideoBodyRejectsInlineMedia(t *testing.T) { _, err := newAPIChannel1VideoBody(canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{Model: "seedance-2.0"}, ReferenceImages: []providerMedia{{ID: "image", DataURL: testReferenceImageDataURL}}, }) if err == nil || !strings.Contains(err.Error(), "公网 HTTP(S) URL") { t.Fatalf("newAPIChannel1VideoBody() error = %v", err) } } func TestRunNewAPIChannel1VideoTaskDownloadsSucceededObject(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") paths := make([]string, 0, 3) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.Method+" "+r.URL.Path) switch r.Method + " " + r.URL.Path { case "POST /v1/videos": if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") { t.Errorf("Content-Type = %q", contentType) } var body map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode request: %v", err) } if body["model"] != "seedance-2.0" { t.Errorf("body = %#v", body) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"channel-1-task","task_id":"channel-1-task","status":"RUNNING"}`)) case "GET /v1/videos/channel-1-task": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"channel-1-task","status":"SUCCEEDED","object":"` + server.URL + `/video.mp4"}`)) case "GET /video.mp4": w.Header().Set("Content-Type", "video/mp4") _, _ = w.Write([]byte("video")) default: http.NotFound(w, r) } })) defer server.Close() result, err := runNewAPIChannel1VideoTask(context.Background(), canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "seedance-2.0", InterfaceType: "newapi-channel-1"}, }) if err != nil { t.Fatalf("runNewAPIChannel1VideoTask() error = %v", err) } video := result["video"].(map[string]interface{}) if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" { t.Fatalf("video = %#v", video) } want := "POST /v1/videos,GET /v1/videos/channel-1-task,GET /video.mp4" if got := strings.Join(paths, ","); got != want { t.Fatalf("paths = %q, want %q", got, want) } } func TestRunNewAPIChannel2VideoTaskDownloadsTemporaryResult(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") paths := make([]string, 0, 3) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.Method+" "+r.URL.Path) switch r.Method + " " + r.URL.Path { case "POST /v1/video/generations": if auth := r.Header.Get("Authorization"); auth != "Bearer test-key" { t.Errorf("Authorization = %q", auth) } var body map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode request: %v", err) } if body["model"] != "grok-image-video" || body["seconds"] != "10" || body["aspect_ratio"] != "9:16" || body["resolution"] != "720p" { t.Errorf("body = %#v", body) } images, ok := body["image_urls"].([]interface{}) if !ok || len(images) != 2 || images[0] != testReferenceImageDataURL { t.Errorf("image_urls = %#v", body["image_urls"]) } videos, _ := body["video_urls"].([]interface{}) audios, _ := body["audio_urls"].([]interface{}) if len(videos) != 1 || len(audios) != 1 || body["generate_audio"] != true { t.Errorf("multi-reference body = %#v", body) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"task_id":"grok-task","status":"queued"}`)) case "GET /v1/video/generations/grok-task": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"grok-task","status":"SUCCESS","result_url":"` + server.URL + `/video.mp4"}}`)) case "GET /video.mp4": w.Header().Set("Content-Type", "video/mp4") _, _ = w.Write([]byte("video")) default: http.NotFound(w, r) } })) defer server.Close() result, err := runVideoTask(context.Background(), canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "grok-image-video", InterfaceType: "newapi-channel-2", VideoSeconds: "15", Size: "720x1280", VQuality: "high"}, ReferenceImages: []providerMedia{ {ID: "image-1", DataURL: testReferenceImageDataURL}, {ID: "image-2", DataURL: testReferenceImageDataURL}, }, ReferenceVideos: []providerMedia{{ID: "video-1", URL: server.URL + "/reference.mp4"}}, ReferenceAudios: []providerMedia{{ID: "audio-1", URL: server.URL + "/reference.mp3"}}, Metadata: map[string]interface{}{"videoEditOperation": "image_to_video"}, }) if err != nil { t.Fatalf("runVideoTask() error = %v", err) } video := result["video"].(map[string]interface{}) if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" { t.Fatalf("video = %#v", video) } want := "POST /v1/video/generations,GET /v1/video/generations/grok-task,GET /video.mp4" if got := strings.Join(paths, ","); got != want { t.Fatalf("paths = %q, want %q", got, want) } } func TestRunGeminiVeoVideoTaskUsesLongRunningOperation(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") paths := make([]string, 0, 3) var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.Method+" "+r.URL.Path) if r.Header.Get("x-goog-api-key") != "test-key" { t.Errorf("x-goog-api-key = %q", r.Header.Get("x-goog-api-key")) } switch r.Method + " " + r.URL.Path { case "POST /v1beta/models/veo-test:predictLongRunning": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"name":"operations/op-1"}`)) case "GET /v1beta/operations/op-1": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"done":true,"response":{"generatedSamples":[{"video":{"uri":"` + server.URL + `/video.mp4"}}]}}`)) case "GET /video.mp4": w.Header().Set("Content-Type", "video/mp4") _, _ = w.Write([]byte("video")) default: http.NotFound(w, r) } })) defer server.Close() result, err := runVideoTask(context.Background(), canvasGenerationInput{ Prompt: "make it move", Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", APIFormat: "gemini", Model: "veo-test", InterfaceType: "gemini-veo", VideoSeconds: "6", Size: "16:9", VQuality: "720"}, }) if err != nil { t.Fatalf("runVideoTask() error = %v", err) } video := result["video"].(map[string]interface{}) if video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" { t.Fatalf("video = %#v", video) } want := "POST /v1beta/models/veo-test:predictLongRunning,GET /v1beta/operations/op-1,GET /video.mp4" if got := strings.Join(paths, ","); got != want { t.Fatalf("paths = %q, want %q", got, want) } } func TestNewAPIChannel2SingleImageModelsRequireOneReference(t *testing.T) { _, err := newAPIChannel2VideoBody(canvasGenerationInput{Config: providerConfig{Model: "grok-video-1.5", VideoSeconds: "6"}}) if err == nil { t.Fatal("newAPIChannel2VideoBody() error = nil") } if !strings.Contains(err.Error(), "当前 0 张") { t.Fatalf("newAPIChannel2VideoBody() error = %q", err) } } func TestNewAPIChannel2SingleImageModelUsesReferenceForStaleTextToVideoMetadata(t *testing.T) { body, err := newAPIChannel2VideoBody(canvasGenerationInput{ Config: providerConfig{Model: "grok-video-1.5", VideoSeconds: "6"}, ReferenceImages: []providerMedia{{ID: "image-1", DataURL: testReferenceImageDataURL}}, Metadata: map[string]interface{}{"videoEditOperation": "text_to_video"}, }) if err != nil { t.Fatalf("newAPIChannel2VideoBody() error = %v", err) } images, ok := body["image_urls"].([]string) if !ok || len(images) != 1 || images[0] != testReferenceImageDataURL { t.Fatalf("image_urls = %#v", body["image_urls"]) } } func TestNewAPIChannel2OrdersFramesBeforeReferenceImages(t *testing.T) { body, err := newAPIChannel2VideoBody(canvasGenerationInput{ Config: providerConfig{Model: "Seedance 2 Mini", VideoSeconds: "10"}, ReferenceImages: []providerMedia{ {ID: "character", DataURL: "data:image/png;base64,Y2hhcmFjdGVy"}, {ID: "last-frame", DataURL: "data:image/png;base64,bGFzdA=="}, {ID: "first-frame", DataURL: "data:image/png;base64,Zmlyc3Q="}, }, Metadata: map[string]interface{}{"videoStartFrameNodeId": "first-frame", "videoEndFrameNodeId": "last-frame", "videoEditOperation": "image_to_video"}, }) if err != nil { t.Fatalf("newAPIChannel2VideoBody() error = %v", err) } images, ok := body["image_urls"].([]string) want := []string{"data:image/png;base64,Zmlyc3Q=", "data:image/png;base64,bGFzdA==", "data:image/png;base64,Y2hhcmFjdGVy"} if !ok || !reflect.DeepEqual(images, want) { t.Fatalf("image_urls = %#v, want %#v", body["image_urls"], want) } } func TestNewAPIChannel2RejectsMissingConfiguredFrame(t *testing.T) { _, err := newAPIChannel2VideoBody(canvasGenerationInput{ Config: providerConfig{Model: "Seedance 2 Mini", VideoSeconds: "10"}, ReferenceImages: []providerMedia{{ID: "character", DataURL: testReferenceImageDataURL}}, Metadata: map[string]interface{}{"videoStartFrameNodeId": "missing-frame", "videoEditOperation": "image_to_video"}, }) if err == nil || !strings.Contains(err.Error(), "首帧参考图未包含") { t.Fatalf("newAPIChannel2VideoBody() error = %v", err) } } func TestValidateGenerationInterfaceRejectsMismatchedType(t *testing.T) { if err := validateGenerationInterface("video", "chat-completion"); err == nil { t.Fatal("validateGenerationInterface() error = nil") } if err := validateGenerationInterface("video", "newapi-channel-1"); err != nil { t.Fatalf("validateGenerationInterface() error = %v", err) } if err := validateGenerationInterface("video", "newapi-channel-2"); err != nil { t.Fatalf("validateGenerationInterface() error = %v", err) } if err := validateGenerationInterface("video", "xai-video"); err != nil { t.Fatalf("validateGenerationInterface() error = %v", err) } } func TestProcessTaskValidatesInterfaceBeforeHydratingMedia(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) defer server.Close() input := canvasGenerationInput{ Mode: "video", Prompt: "make it move", Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "key", Model: "text-model", InterfaceType: "chat-completion"}, ReferenceImages: []providerMedia{{StorageKey: "resource:missing"}}, } raw, _ := json.Marshal(input) _, err := (&Service{}).processCanvasGenerationTask(context.Background(), "user-1", "video_generate", "", string(raw)) if err == nil || !strings.Contains(err.Error(), "不支持video生成") { t.Fatalf("processCanvasGenerationTask() error = %v", err) } }