From 7f0229463ea9ea96fcc13f7e73368540b69143a4 Mon Sep 17 00:00:00 2001 From: jax-novita Date: Fri, 14 Aug 2026 10:04:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(media):=20=E8=A7=86=E9=A2=91=E6=B8=A0?= =?UTF-8?q?=E9=81=93=20-=20=E6=96=B0=E5=A2=9E=20Novita=20=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E8=A7=86=E9=A2=91=E7=94=9F=E6=88=90=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: jax-novita Original-PR: https://github.com/ddcat-ai/open-ai-canvas/pull/206 --- backend/internal/handler/security.go | 29 +++-- backend/internal/handler/security_test.go | 4 + backend/internal/model/models.go | 1 + backend/internal/service/model_capability.go | 6 + backend/internal/service/provider.go | 125 ++++++++++++++++++- backend/internal/service/provider_test.go | 80 ++++++++++++ web/src/components/model-protocol-picker.tsx | 2 +- web/src/lib/model-capabilities.ts | 8 ++ web/src/lib/model-protocols.ts | 4 +- web/src/services/api/video.ts | 57 ++++++++- web/src/stores/use-config-store.ts | 1 + 11 files changed, 302 insertions(+), 15 deletions(-) diff --git a/backend/internal/handler/security.go b/backend/internal/handler/security.go index b320bac05..472951c0d 100644 --- a/backend/internal/handler/security.go +++ b/backend/internal/handler/security.go @@ -23,15 +23,16 @@ import ( ) var ( - runtimeService *service.Service - geminiGeneratePath = regexp.MustCompile(`^/models/([^/:]+):(generateContent|streamGenerateContent)$`) - customGeminiRelayPath = regexp.MustCompile(`(?:^|/)models/[^/:]+:(generateContent|streamGenerateContent|predictLongRunning)$`) - customVideoTaskPath = regexp.MustCompile(`(?:^|/)video/generations/[^/]+$`) - customXAIVideoTaskPath = regexp.MustCompile(`(?:^|/)videos/[^/]+$`) - customVideoContentPath = regexp.MustCompile(`(?:^|/)videos/[^/]+/content$`) - customArkVideoTaskPath = regexp.MustCompile(`(?:^|/)contents/generations/tasks/[^/]+$`) - customGeminiOperationPath = regexp.MustCompile(`(?:^|/)(?:models/[^/]+/)?operations/[^/]+$`) - openAIPostEndpoints = map[string]bool{ + runtimeService *service.Service + geminiGeneratePath = regexp.MustCompile(`^/models/([^/:]+):(generateContent|streamGenerateContent)$`) + customGeminiRelayPath = regexp.MustCompile(`(?:^|/)models/[^/:]+:(generateContent|streamGenerateContent|predictLongRunning)$`) + customVideoTaskPath = regexp.MustCompile(`(?:^|/)video/generations/[^/]+$`) + customXAIVideoTaskPath = regexp.MustCompile(`(?:^|/)videos/[^/]+$`) + customVideoContentPath = regexp.MustCompile(`(?:^|/)videos/[^/]+/content$`) + customArkVideoTaskPath = regexp.MustCompile(`(?:^|/)contents/generations/tasks/[^/]+$`) + customGeminiOperationPath = regexp.MustCompile(`(?:^|/)(?:models/[^/]+/)?operations/[^/]+$`) + customNovitaTaskResultPath = regexp.MustCompile(`(?:^|/)async/task-result$`) + openAIPostEndpoints = map[string]bool{ "/responses": true, "/chat/completions": true, "/images/generations": true, "/images/edits": true, "/audio/speech": true, } @@ -62,6 +63,12 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont return errors.New("自定义渠道调用格式无效") } if method == http.MethodGet { + if customNovitaTaskResultPath.MatchString(requestPath) { + if len(query) == 1 && len(query["task_id"]) == 1 && strings.TrimSpace(query.Get("task_id")) != "" { + return nil + } + return errors.New("自定义渠道不允许访问该上游接口") + } allowed := requestPath == "/models" || strings.HasSuffix(requestPath, "/models") if apiFormat == "openai" { allowed = allowed || customVideoTaskPath.MatchString(requestPath) || customXAIVideoTaskPath.MatchString(requestPath) || customVideoContentPath.MatchString(requestPath) || customArkVideoTaskPath.MatchString(requestPath) @@ -82,7 +89,7 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont } if apiFormat == "openai" { multipartAllowed := mediaType == "multipart/form-data" && (strings.HasSuffix(requestPath, "/images/edits") || strings.HasSuffix(requestPath, "/videos")) - jsonAllowed := mediaType == "application/json" && (strings.HasSuffix(requestPath, "/responses") || strings.HasSuffix(requestPath, "/chat/completions") || strings.HasSuffix(requestPath, "/images/generations") || strings.HasSuffix(requestPath, "/images/edits") || strings.HasSuffix(requestPath, "/audio/speech") || strings.HasSuffix(requestPath, "/video/generations") || strings.HasSuffix(requestPath, "/videos/generations") || strings.HasSuffix(requestPath, "/videos") || strings.HasSuffix(requestPath, "/contents/generations/tasks")) + jsonAllowed := mediaType == "application/json" && (strings.HasSuffix(requestPath, "/responses") || strings.HasSuffix(requestPath, "/chat/completions") || strings.HasSuffix(requestPath, "/images/generations") || strings.HasSuffix(requestPath, "/images/edits") || strings.HasSuffix(requestPath, "/audio/speech") || strings.HasSuffix(requestPath, "/video/generations") || strings.HasSuffix(requestPath, "/videos/generations") || strings.HasSuffix(requestPath, "/videos") || strings.HasSuffix(requestPath, "/contents/generations/tasks") || strings.HasSuffix(requestPath, "/video/create")) if len(query) != 0 || (!multipartAllowed && !jsonAllowed) { return errors.New("自定义渠道不允许访问该上游接口") } @@ -189,7 +196,7 @@ func interfaceAllowsProxyPath(interfaceType model.ChannelInterfaceType, requestP return requestPath == "/images/generations" case model.ChannelInterfaceOpenAIAudio: return requestPath == "/audio/speech" - case model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo: + case model.ChannelInterfaceAsyncAudio, model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceVolcengineJiMengVideo, model.ChannelInterfaceGeminiVeo, model.ChannelInterfaceNovitaVideo: return false default: return true diff --git a/backend/internal/handler/security_test.go b/backend/internal/handler/security_test.go index db48d7462..73f18c25f 100644 --- a/backend/internal/handler/security_test.go +++ b/backend/internal/handler/security_test.go @@ -46,6 +46,8 @@ func TestAuthorizeCustomRelayAllowsModelsAndAgentEndpoints(t *testing.T) { {method: http.MethodGet, target: "https://api.example.com/v1/video/generations/task-1", apiFormat: "openai"}, {method: http.MethodPost, target: "https://api.x.ai/v1/videos/generations", apiFormat: "openai", contentType: "application/json"}, {method: http.MethodGet, target: "https://api.x.ai/v1/videos/request-1", apiFormat: "openai"}, + {method: http.MethodPost, target: "https://api.novita.ai/v3/video/create", apiFormat: "openai", contentType: "application/json"}, + {method: http.MethodGet, target: "https://api.novita.ai/v3/async/task-result?task_id=task-1", apiFormat: "openai"}, {method: http.MethodPost, target: "https://ark.cn-beijing.volces.com/api/v3/images/generations", apiFormat: "openai", contentType: "application/json"}, {method: http.MethodPost, target: "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks", apiFormat: "openai", contentType: "application/json"}, {method: http.MethodGet, target: "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/task-1", apiFormat: "openai"}, @@ -78,6 +80,8 @@ func TestAuthorizeCustomRelayRejectsArbitraryRequestsAndCredentialQueries(t *tes {method: http.MethodPost, target: "https://api.example.com/v1/account", apiFormat: "openai", contentType: "multipart/form-data; boundary=test"}, {method: http.MethodPost, target: "https://api.example.com/v1/../account/chat/completions", apiFormat: "openai", contentType: "application/json"}, {method: http.MethodPost, target: "https://api.example.com/v1/models/gemini:streamGenerateContent?alt=sse&token=secret", apiFormat: "gemini", contentType: "application/json"}, + {method: http.MethodGet, target: "https://api.novita.ai/v3/async/task-result?task_id=task-1&api_key=secret", apiFormat: "openai"}, + {method: http.MethodGet, target: "https://api.novita.ai/v3/async/task-result", apiFormat: "openai"}, } for _, test := range tests { target, err := url.Parse(test.target) diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 126d67cfc..1bbe08a81 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -73,6 +73,7 @@ const ( ChannelInterfaceVolcengineArkVideo ChannelInterfaceType = "volcengine-ark-video" ChannelInterfaceVolcengineJiMengVideo ChannelInterfaceType = "volcengine-jimeng-video" ChannelInterfaceGeminiVeo ChannelInterfaceType = "gemini-veo" + ChannelInterfaceNovitaVideo ChannelInterfaceType = "novita-video" ApiCallStatusSucceeded ApiCallStatus = "succeeded" ApiCallStatusFailed ApiCallStatus = "failed" diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 944985f2c..3f1ac92e3 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -175,6 +175,12 @@ func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *Mo video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceXAIVideo: video.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false} + case model.ChannelInterfaceNovitaVideo: + video.References.MaxImages, video.References.MaxImageBytes = 1, 10*1024*1024 + video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{5, 10}, Default: 5} + video.Ratios = []string{"16:9", "9:16", "1:1"} + video.Resolutions = []string{"1080p"} + video.DefaultResolution = "1080p" } return &ModelCapabilityConfig{Version: 1, Image: DefaultImageCapabilityConfig(protocol, modelName), Video: video} } diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 3131c4aa2..2fface31e 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -1339,6 +1339,9 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string] if input.Config.InterfaceType == "gemini-veo" { return runGeminiVeoVideoTask(ctx, input) } + if input.Config.InterfaceType == string(model.ChannelInterfaceNovitaVideo) { + return runNovitaVideoTask(ctx, input) + } if input.Config.InterfaceType == "newapi-channel-2" { return runNewAPIChannel2VideoTask(ctx, input) } @@ -1507,6 +1510,126 @@ func runGeminiVeoVideoTask(ctx context.Context, input canvasGenerationInput) (ma return nil, fmt.Errorf("Gemini Veo 视频生成超时(任务 %s)", id) } +func runNovitaVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { + if len(input.ReferenceImages) > 1 || len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0 { + return nil, errors.New("Novita 视频当前只支持 1 张起始图,不支持参考视频或参考音频") + } + id := resumedProviderRequestID(ctx) + if id == "" { + body := map[string]interface{}{ + "model": input.Config.Model, + "prompt": strings.TrimSpace(input.Prompt), + "duration": normalizeNovitaVideoDuration(input.Config.VideoSeconds), + } + if len(input.ReferenceImages) == 1 { + imageValue, err := novitaVideoImageValue(input.ReferenceImages[0]) + if err != nil { + return nil, err + } + body["image"] = imageValue + } else { + body["aspect_ratio"] = normalizeNovitaVideoRatio(input.Config.Size) + } + var created map[string]interface{} + if err := postNovitaJSON(ctx, input.Config, "/video/create", body, &created); err != nil { + return nil, err + } + id = strings.TrimSpace(stringField(created, "task_id")) + } + if id == "" { + return nil, errors.New("Novita 视频接口没有返回任务 ID") + } + for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); { + var result map[string]interface{} + if err := getNovitaJSON(ctx, input.Config, "/async/task-result?task_id="+url.QueryEscape(id), &result); err != nil { + return nil, err + } + task, _ := result["task"].(map[string]interface{}) + switch stringField(task, "status") { + case "TASK_STATUS_SUCCEED": + videos, _ := result["videos"].([]interface{}) + if len(videos) == 0 { + return nil, fmt.Errorf("Novita 视频任务 %s 已完成但没有返回视频", id) + } + first, _ := videos[0].(map[string]interface{}) + videoURL := strings.TrimSpace(stringField(first, "video_url")) + if videoURL == "" { + return nil, fmt.Errorf("Novita 视频任务 %s 已完成但没有返回视频地址", id) + } + data, mimeType, err := getExternalBinary(withProviderRequestKind(ctx, "download"), videoURL) + if err != nil { + return nil, fmt.Errorf("Novita 视频结果下载失败(任务 %s):%w", id, err) + } + mimeType = normalizedMediaMimeType(mimeType, data) + return map[string]interface{}{"mode": "video", "video": map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType}}, nil + case "TASK_STATUS_FAILED": + reason := firstNonEmptyString(stringField(task, "reason"), "上游返回失败") + return nil, fmt.Errorf("Novita 视频生成失败(任务 %s):%s", id, reason) + } + if err := sleepContext(ctx, 5*time.Second); err != nil { + return nil, err + } + } + return nil, fmt.Errorf("Novita 视频生成超时(任务 %s)", id) +} + +func novitaVideoImageValue(media providerMedia) (string, error) { + if isPublicMediaURL(media.URL) { + if _, err := ValidateOutboundURL(media.URL); err != nil { + return "", err + } + return media.URL, nil + } + raw, mimeType, err := mediaBytes(media) + if err != nil { + return "", err + } + return dataURL(mimeType, raw), nil +} + +func normalizeNovitaVideoDuration(value string) string { + if normalizeSeedanceDuration(value) >= 8 { + return "10" + } + return "5" +} + +func normalizeNovitaVideoRatio(value string) string { + switch strings.TrimSpace(value) { + case "16:9", "9:16", "1:1": + return strings.TrimSpace(value) + default: + return "16:9" + } +} + +func novitaVideoURL(baseURL string, path string) string { + base := strings.TrimRight(strings.TrimSpace(baseURL), "/") + return base + "/" + strings.TrimLeft(path, "/") +} + +func postNovitaJSON(ctx context.Context, config providerConfig, path string, body interface{}, target interface{}) error { + data, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, novitaVideoURL(config.BaseURL, path), bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+config.APIKey) + req.Header.Set("Content-Type", "application/json") + ApplyOutboundHeaders(req, config.Headers) + return doJSON(req, target) +} + +func getNovitaJSON(ctx context.Context, config providerConfig, path string, target interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, novitaVideoURL(config.BaseURL, path), nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+config.APIKey) + ApplyOutboundHeaders(req, config.Headers) + return doJSON(req, target) +} + func postGeminiJSON(ctx context.Context, config providerConfig, path string, body interface{}, target interface{}) error { data, _ := json.Marshal(body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, geminiVeoURL(config.BaseURL, path), bytes.NewReader(data)) @@ -2027,7 +2150,7 @@ func validateGenerationInterface(mode string, interfaceType string) error { allowed := map[string]map[string]bool{ "text": {"chat-completion": true, "openai-response": true}, "image": {"openai-image": true, "grok-image": true, "volcengine-ark-image": true, "volcengine-jimeng-image": true}, - "video": {"newapi": true, "newapi-channel-1": true, "newapi-channel-2": true, "xai-video": true, "volcengine-ark-video": true, "volcengine-jimeng-video": true, "gemini-veo": true}, + "video": {"newapi": true, "newapi-channel-1": true, "newapi-channel-2": true, "xai-video": true, "volcengine-ark-video": true, "volcengine-jimeng-video": true, "gemini-veo": true, "novita-video": true}, "audio": {"openai-audio": true, "async-audio": true}, } if allowed[mode] != nil && !allowed[mode][interfaceType] { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 33953c687..82086e85d 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -1278,3 +1278,83 @@ func TestEquivalentStyleProfileJSONIgnoresObjectKeyOrder(t *testing.T) { t.Fatalf("equivalentStyleProfileJSON() equal = %v, err = %v", equal, err) } } + +func TestRunNovitaVideoTaskDownloadsSucceededVideo(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.String()) + switch r.Method + " " + r.URL.Path { + case "POST /video/create": + 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"] != "kling2.5_turbo_pro_t2v" || body["prompt"] != "make it move" || body["duration"] != "5" || body["aspect_ratio"] != "16:9" { + t.Errorf("body = %#v", body) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task_id":"novita-task-1"}`)) + case "GET /async/task-result": + if r.URL.Query().Get("task_id") != "novita-task-1" { + t.Errorf("task_id = %q", r.URL.Query().Get("task_id")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task":{"status":"TASK_STATUS_SUCCEED"},"videos":[{"video_url":"` + server.URL + `/video.mp4"}]}`)) + case "GET /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, APIKey: "test-key", Model: "kling2.5_turbo_pro_t2v", InterfaceType: "novita-video", VideoSeconds: "5", Size: "16:9"}, + }) + 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 /video/create,GET /async/task-result?task_id=novita-task-1,GET /video.mp4" + if got := strings.Join(paths, ","); got != want { + t.Fatalf("paths = %q, want %q", got, want) + } +} + +func TestRunNovitaVideoTaskReturnsFailureReason(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 /video/create": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task_id":"novita-task-2"}`)) + case "GET /async/task-result": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task":{"status":"TASK_STATUS_FAILED","reason":"content violates policy"}}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + _, err := runVideoTask(context.Background(), canvasGenerationInput{ + Prompt: "make it move", + Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "kling2.5_turbo_pro_t2v", InterfaceType: "novita-video"}, + }) + if err == nil || !strings.Contains(err.Error(), "content violates policy") { + t.Fatalf("runVideoTask() error = %v, want reason in message", err) + } +} diff --git a/web/src/components/model-protocol-picker.tsx b/web/src/components/model-protocol-picker.tsx index c6f9dea28..f7aac2648 100644 --- a/web/src/components/model-protocol-picker.tsx +++ b/web/src/components/model-protocol-picker.tsx @@ -145,7 +145,7 @@ function ProtocolBrandMark({ protocol, compact = false }: { protocol: ModelProto if (protocol.value === "chat-completion") return ; if (protocol.value.startsWith("volcengine-jimeng-")) return ; if (protocol.value.startsWith("volcengine-")) return ; - if (protocol.value.startsWith("newapi-channel-")) return ; + if (protocol.value.startsWith("newapi-channel-") || protocol.value === "novita-video") return ; const brand = protocol.value === "gemini-veo" ? "gemini" : protocol.value === "grok-image" || protocol.value === "xai-video" ? "grok" : "openai"; return ; } diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 5f847d18a..c7f19f3d3 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -160,6 +160,14 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = " video.generateAudio = { supported: true, default: true }; } if (protocol === "volcengine-ark-video") video.watermark = { supported: true, default: false }; + if (protocol === "novita-video") { + video.references.maxImages = 1; + video.references.maxImageBytes = 10 * 1024 * 1024; + video.duration = { selection: "enum", values: [5, 10], default: 5 }; + video.ratios = ["16:9", "9:16", "1:1"]; + video.resolutions = ["1080p"]; + video.defaultResolution = "1080p"; + } return { version: 1, image: defaultImageCapabilityConfig(protocol, model), video }; } diff --git a/web/src/lib/model-protocols.ts b/web/src/lib/model-protocols.ts index 081e517b2..ab938dffb 100644 --- a/web/src/lib/model-protocols.ts +++ b/web/src/lib/model-protocols.ts @@ -13,7 +13,8 @@ export type ModelProtocol = | "xai-video" | "volcengine-ark-video" | "volcengine-jimeng-video" - | "gemini-veo"; + | "gemini-veo" + | "novita-video"; export type ProtocolCapability = "text" | "image" | "video" | "audio"; @@ -59,6 +60,7 @@ export const MODEL_PROTOCOLS: ModelProtocolDefinition[] = [ }, { value: "volcengine-jimeng-video", label: "即梦官方视频", capability: "video", create: "POST CVSync2AsyncSubmitTask", poll: "POST CVSync2AsyncGetResult", contentType: "application/json + AK/SK 签名", media: "文本或一张首帧图,模型标识填写 req_key" }, { value: "gemini-veo", label: "Gemini Veo", capability: "video", create: "POST /v1beta/models/{model}:predictLongRunning", poll: "GET /v1beta/{operation_name}", contentType: "application/json", media: "文本与单张起始图" }, + { value: "novita-video", label: "Novita 视频", capability: "video", create: "POST /v3/video/create", poll: "GET /v3/async/task-result?task_id={id}", contentType: "application/json", media: "文本或单张起始图" }, ]; export const MODEL_PROTOCOL_OPTIONS = protocolGroups(MODEL_PROTOCOLS.filter((item) => !item.value.startsWith("volcengine-jimeng-"))); diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index d941784e1..c2c3911ca 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -28,7 +28,7 @@ type ApiEnvelope = T | { code?: number; data?: T | null; msg?: string }; type RequestOptions = { signal?: AbortSignal }; export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string }; -export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "video-generations" | "gemini-veo"; model: string }; +export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "video-generations" | "gemini-veo" | "novita"; model: string }; export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string }; function aiApiUrl(config: AiConfig, path: string) { @@ -68,6 +68,9 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string if (requestConfig.interfaceType === "gemini-veo") { return createGeminiVeoTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options); } + if (requestConfig.interfaceType === "novita-video") { + return createNovitaVideoTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options); + } if (requestConfig.interfaceType === "volcengine-ark-video") { return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options); } @@ -100,6 +103,7 @@ export async function pollVideoGenerationTask(config: AiConfig, task: VideoGener if (task.provider === "seedance") return pollSeedanceTask(requestConfig, task, options); if (task.provider === "video-generations") return pollVideoGenerationsTask(requestConfig, task, options); if (task.provider === "gemini-veo") return pollGeminiVeoTask(requestConfig, task, options); + if (task.provider === "novita") return pollNovitaVideoTask(requestConfig, task, options); return pollOpenAIVideoTask(requestConfig, task, options); } @@ -222,6 +226,57 @@ function geminiVeoHeaders(config: ResolvedAiConfig, contentType?: string) { return { "x-goog-api-key": config.apiKey, ...(contentType ? { "Content-Type": contentType } : {}) }; } +type NovitaVideoResult = { task?: { status?: string; reason?: string }; videos?: Array<{ video_url?: string }> }; + +async function createNovitaVideoTask(config: ResolvedAiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions): Promise { + if (references.length > 1 || videoReferences.length || audioReferences.length) throw new Error("Novita 视频当前只支持 1 张起始图,不支持参考视频或音频"); + const payload: Record = { + model: modelOptionName(model), + prompt: prompt.trim(), + duration: normalizeNovitaVideoDuration(config.videoSeconds), + }; + if (references[0]) { + payload.image = isPublicMediaUrl(references[0].url || "") ? references[0].url : await imageToDataUrl(references[0]); + } else { + payload.aspect_ratio = normalizeNovitaVideoRatio(config.size); + } + try { + const created = await channelPost<{ task_id?: string }>(config, novitaVideoUrl(config, "/video/create"), payload, options); + if (!created.task_id) throw new Error("Novita 视频接口没有返回任务 ID"); + return { id: created.task_id, provider: "novita", model }; + } catch (error) { + throw new Error(readAxiosError(error, "Novita 视频任务创建失败")); + } +} + +async function pollNovitaVideoTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise { + try { + const result = await channelGet(config, novitaVideoUrl(config, `/async/task-result?task_id=${encodeURIComponent(task.id)}`), options); + const status = result.task?.status || ""; + if (status === "TASK_STATUS_SUCCEED") { + const url = result.videos?.[0]?.video_url || ""; + if (!url) return { status: "failed", error: "Novita 视频任务已完成但没有返回视频地址" }; + return { status: "completed", result: await videoResultFromUrl(url, options) }; + } + if (status === "TASK_STATUS_FAILED") return { status: "failed", error: result.task?.reason || "视频生成失败" }; + return { status: "pending" }; + } catch (error) { + throw new Error(readAxiosError(error, "Novita 视频任务查询失败")); + } +} + +function novitaVideoUrl(config: ResolvedAiConfig, path: string) { + return `${config.baseUrl.replace(/\/+$/, "")}${path}`; +} + +function normalizeNovitaVideoDuration(value: string) { + return normalizeSeedanceDuration(value) >= 8 ? "10" : "5"; +} + +function normalizeNovitaVideoRatio(value: string) { + return value === "16:9" || value === "9:16" || value === "1:1" ? value : "16:9"; +} + async function channelPost(config: ResolvedAiConfig, upstreamUrl: string, body: unknown, options?: RequestOptions) { const request = channelRequest(config, upstreamUrl, aiHeaders(config, "application/json")); return (await axios.post(request.url, body, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal })).data; diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index c2d166ec1..d48a07ff0 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -489,6 +489,7 @@ export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) { export function defaultBaseUrlForChannelInterface(interfaceType?: ChannelInterfaceType) { if (interfaceType === "gemini-veo") return GEMINI_BASE_URL; + if (interfaceType === "novita-video") return "https://api.novita.ai/v3"; if (interfaceType === "volcengine-ark-image" || interfaceType === "volcengine-ark-video") return "https://ark.cn-beijing.volces.com/api/v3"; if (interfaceType === "volcengine-jimeng-image" || interfaceType === "volcengine-jimeng-video") return "https://visual.volcengineapi.com"; if (interfaceType === "grok-image" || interfaceType === "newapi" || interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2" || interfaceType === "xai-video") return "";