diff --git a/relay/constant/task.go b/relay/constant/task.go index 77dbe52..5725421 100644 --- a/relay/constant/task.go +++ b/relay/constant/task.go @@ -9,6 +9,7 @@ const ( TaskPlatformSuno TaskPlatform = "suno" TaskPlatformMidjourney TaskPlatform = "midjourney" TaskPlatformVolcengine TaskPlatform = "volcengine" + TaskPlatformAli TaskPlatform = "ali" ) // TaskAction 异步任务动作类型 @@ -81,6 +82,8 @@ func ProviderTypeToTaskPlatform(p ProviderType) (TaskPlatform, bool) { return TaskPlatformMidjourney, true case ProviderVolcengine: return TaskPlatformVolcengine, true + case ProviderAli: + return TaskPlatformAli, true default: return "", false } diff --git a/relay/handler/task_handler.go b/relay/handler/task_handler.go index 2b308e1..050367e 100644 --- a/relay/handler/task_handler.go +++ b/relay/handler/task_handler.go @@ -14,6 +14,7 @@ import ( "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/taskchannel" + _ "github.com/qianfree/team-api/relay/taskchannel/ali" _ "github.com/qianfree/team-api/relay/taskchannel/kling" "github.com/qianfree/team-api/relay/taskchannel/midjourney" _ "github.com/qianfree/team-api/relay/taskchannel/sora" diff --git a/relay/taskchannel/ali/adaptor.go b/relay/taskchannel/ali/adaptor.go new file mode 100644 index 0000000..15eb06d --- /dev/null +++ b/relay/taskchannel/ali/adaptor.go @@ -0,0 +1,351 @@ +package ali + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/taskchannel" +) + +func init() { + taskchannel.Register(constant.ProviderAli, func() common.TaskAdaptor { + return &AliVideoAdaptor{} + }) +} + +// ==================== 请求/响应结构体 ==================== + +// dashScopeVideoRequest DashScope 视频生成请求 +type dashScopeVideoRequest struct { + Model string `json:"model"` + Input dashScopeVideoInput `json:"input"` + Parameters *dashScopeVideoParams `json:"parameters,omitempty"` +} + +type dashScopeVideoInput struct { + Prompt string `json:"prompt"` + NegativePrompt string `json:"negative_prompt,omitempty"` + AudioURL string `json:"audio_url,omitempty"` +} + +type dashScopeVideoParams struct { + Resolution string `json:"resolution,omitempty"` + Ratio string `json:"ratio,omitempty"` + Size string `json:"size,omitempty"` + Duration *int `json:"duration,omitempty"` + PromptExtend *bool `json:"prompt_extend,omitempty"` + Watermark *bool `json:"watermark,omitempty"` + Seed *int `json:"seed,omitempty"` +} + +// dashScopeSubmitResponse DashScope 异步提交响应 +type dashScopeSubmitResponse struct { + Output struct { + TaskID string `json:"task_id"` + TaskStatus string `json:"task_status"` + Code string `json:"code"` + Message string `json:"message"` + } `json:"output"` + RequestID string `json:"request_id"` + Code string `json:"code"` + Message string `json:"message"` +} + +// dashScopeTaskResponse DashScope 异步任务查询响应 +type dashScopeTaskResponse struct { + Output struct { + TaskID string `json:"task_id"` + TaskStatus string `json:"task_status"` + VideoURL string `json:"video_url"` + Code string `json:"code"` + Message string `json:"message"` + } `json:"output"` + Usage struct { + Duration float64 `json:"duration"` + VideoCount int `json:"video_count"` + OutputVideoDuration int `json:"output_video_duration"` + SR int `json:"SR"` + Ratio string `json:"ratio"` + } `json:"usage"` + RequestID string `json:"request_id"` +} + +// ==================== Adaptor 实现 ==================== + +type AliVideoAdaptor struct { + info *common.RelayInfo +} + +func (a *AliVideoAdaptor) Init(info *common.RelayInfo) { + a.info = info +} + +func (a *AliVideoAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body"} + } + if _, ok := req["model"]; !ok { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"} + } + return nil +} + +func (a *AliVideoAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 { + ratios := map[string]float64{"base": 1.0} + var req map[string]any + if json.Unmarshal(body, &req) != nil { + return ratios + } + // duration 影响计费,传递给计费引擎 + if metadata, ok := req["metadata"].(map[string]any); ok { + if v, ok := metadata["duration"].(float64); ok && v > 0 { + ratios["duration"] = v + } + } + if seconds, ok := req["seconds"].(string); ok { + if d, err := parseInt(seconds); err == nil && d > 0 { + ratios["duration"] = float64(d) + } + } + return ratios +} + +func (a *AliVideoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { + return nil +} + +func (a *AliVideoAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) { + baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/") + return baseURL + "/api/v1/services/aigc/video-generation/video-synthesis", nil +} + +func (a *AliVideoAdaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + header.Set("X-DashScope-Async", "enable") + return nil +} + +func (a *AliVideoAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return strings.NewReader(string(body)), nil + } + + dsReq := dashScopeVideoRequest{ + Input: dashScopeVideoInput{}, + } + + // 模型名 + if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" { + dsReq.Model = info.ChannelMeta.UpstreamModelName + } else if m, ok := req["model"].(string); ok { + dsReq.Model = m + } + + // prompt → input.prompt + if v, ok := req["prompt"].(string); ok { + dsReq.Input.Prompt = v + } + + // 从 metadata 提取参数 + params := &dashScopeVideoParams{} + hasParams := false + + if metadata, ok := req["metadata"].(map[string]any); ok { + if v, ok := metadata["negative_prompt"].(string); ok { + dsReq.Input.NegativePrompt = v + } + if v, ok := metadata["audio_url"].(string); ok { + dsReq.Input.AudioURL = v + } + + // wan2.7+ 使用 resolution + ratio + if v, ok := metadata["resolution"].(string); ok { + params.Resolution = v + hasParams = true + } + if v, ok := metadata["ratio"].(string); ok { + params.Ratio = v + hasParams = true + } + // wan2.6 及以下使用 size + if v, ok := metadata["size"].(string); ok { + params.Size = strings.ReplaceAll(v, "x", "*") + hasParams = true + } + if v, ok := metadata["duration"].(float64); ok && v > 0 { + d := int(v) + params.Duration = &d + hasParams = true + } + if v, ok := metadata["prompt_extend"].(bool); ok { + params.PromptExtend = &v + hasParams = true + } + if v, ok := metadata["watermark"].(bool); ok { + params.Watermark = &v + hasParams = true + } + if v, ok := metadata["seed"].(float64); ok { + s := int(v) + params.Seed = &s + hasParams = true + } + } + + // seconds 字段映射到 duration + if seconds, ok := req["seconds"].(string); ok { + if d, err := parseInt(seconds); err == nil && d > 0 { + params.Duration = &d + hasParams = true + } + } + + if hasParams { + dsReq.Parameters = params + } + + data, err := json.Marshal(dsReq) + if err != nil { + return nil, fmt.Errorf("marshal dashscope video request: %w", err) + } + return strings.NewReader(string(data)), nil +} + +func (a *AliVideoAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + url, err := a.BuildRequestURL(info) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, url, requestBody) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + if err := a.BuildRequestHeader(req.Header, info); err != nil { + return nil, fmt.Errorf("setup header: %w", err) + } + client := &http.Client{Timeout: 120 * 1e9} + return client.Do(req) +} + +func (a *AliVideoAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo) (string, []byte, *common.TaskError) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read response failed"} + } + + // DashScope 错误时可能在顶层返回 code + message + var errResp dashScopeSubmitResponse + if json.Unmarshal(body, &errResp) == nil && errResp.Code != "" { + return "", body, &common.TaskError{ + StatusCode: resp.StatusCode, + Message: errResp.Message, + ErrCode: errResp.Code, + } + } + + if resp.StatusCode != http.StatusOK { + return "", body, &common.TaskError{ + StatusCode: resp.StatusCode, + Message: string(body), + } + } + + var result dashScopeSubmitResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse response failed"} + } + + if result.Output.TaskID == "" { + // DashScope 有时在 200 响应中返回错误 + if result.Output.Code != "" { + return "", body, &common.TaskError{ + StatusCode: resp.StatusCode, + Message: result.Output.Message, + ErrCode: result.Output.Code, + } + } + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "upstream returned empty task id"} + } + + return result.Output.TaskID, body, nil +} + +func (a *AliVideoAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { + var data struct { + TaskID string `json:"task_id"` + } + if err := json.Unmarshal(taskData, &data); err != nil { + return nil, fmt.Errorf("ali: invalid task data: %w", err) + } + + url := fmt.Sprintf("%s/api/v1/tasks/%s", strings.TrimRight(baseURL, "/"), data.TaskID) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 30 * 1e9} + return client.Do(req) +} + +func (a *AliVideoAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) { + var resp dashScopeTaskResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("ali: parse task result: %w", err) + } + + info := &common.TaskInfo{Data: body} + + switch resp.Output.TaskStatus { + case "PENDING": + info.Status = common.TaskStatusSubmitted + info.Progress = "10%" + case "RUNNING": + info.Status = common.TaskStatusInProgress + info.Progress = "50%" + case "SUCCEEDED": + info.Status = common.TaskStatusSuccess + info.Progress = "100%" + info.ResultURL = resp.Output.VideoURL + case "FAILED": + info.Status = common.TaskStatusFailure + info.FailReason = resp.Output.Message + case "CANCELED", "UNKNOWN": + info.Status = common.TaskStatusFailure + info.FailReason = fmt.Sprintf("task status: %s", resp.Output.TaskStatus) + default: + info.Status = common.TaskStatusSubmitted + info.Progress = "10%" + } + + return info, nil +} + +func (a *AliVideoAdaptor) GetModelList() []string { + return ModelList +} + +func (a *AliVideoAdaptor) GetChannelName() string { + return channelName +} + +// ==================== 辅助函数 ==================== + +func parseInt(s string) (int, error) { + var n int + _, err := fmt.Sscanf(s, "%d", &n) + return n, err +} diff --git a/relay/taskchannel/ali/constants.go b/relay/taskchannel/ali/constants.go new file mode 100644 index 0000000..3630373 --- /dev/null +++ b/relay/taskchannel/ali/constants.go @@ -0,0 +1,14 @@ +package ali + +// ModelList 阿里云 DashScope 支持视频生成的模型列表 +var ModelList = []string{ + "wan2.7-t2v-2026-04-25", + "wan2.6-t2v", + "wan2.6-t2v-us", + "wan2.5-t2v-preview", + "wan2.2-t2v-plus", + "wanx2.1-t2v-turbo", + "wanx2.1-t2v-plus", +} + +const channelName = "AliVideo" diff --git a/relay/taskchannel/kling/adaptor.go b/relay/taskchannel/kling/adaptor.go index 3ce4626..e57e241 100644 --- a/relay/taskchannel/kling/adaptor.go +++ b/relay/taskchannel/kling/adaptor.go @@ -15,33 +15,118 @@ import ( const channelName = "Kling" +// ModelList 可灵视频生成模型列表 +var ModelList = []string{ + "kling-v1", + "kling-v1-5", + "kling-v1-6", + "kling-v2-master", + "kling-v2-1-master", + "kling-v2-5-turbo", + "kling-v2-6", + "kling-v3", + "kling-video-o1", +} + func init() { taskchannel.Register(constant.ProviderKling, func() common.TaskAdaptor { return &KlingAdaptor{} }) } +// ==================== 请求/响应结构体 ==================== + +// klingRequest 可灵视频生成请求(文生视频 + 图生视频共用) +type klingRequest struct { + ModelName string `json:"model_name"` + Prompt string `json:"prompt,omitempty"` + NegativePrompt string `json:"negative_prompt,omitempty"` + Mode string `json:"mode,omitempty"` + Duration string `json:"duration,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + CfgScale *float64 `json:"cfg_scale,omitempty"` + Image string `json:"image,omitempty"` + ImageTail string `json:"image_tail,omitempty"` + Sound string `json:"sound,omitempty"` + CameraControl map[string]any `json:"camera_control,omitempty"` +} + +// klingSubmitResponse 可灵任务提交响应 +type klingSubmitResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + TaskID string `json:"task_id"` + TaskStatus string `json:"task_status"` + } `json:"data"` + RequestID string `json:"request_id"` +} + +// klingTaskResponse 可灵任务查询响应 +type klingTaskResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + TaskID string `json:"task_id"` + TaskStatus string `json:"task_status"` + TaskStatusMsg string `json:"task_status_msg"` + TaskResult struct { + Videos []struct { + ID string `json:"id"` + URL string `json:"url"` + Duration string `json:"duration"` + } `json:"videos"` + } `json:"task_result"` + } `json:"data"` + RequestID string `json:"request_id"` +} + +// ==================== Adaptor 实现 ==================== + type KlingAdaptor struct { - info *common.RelayInfo + info *common.RelayInfo + taskType string // text2video / image2video / omni-video } func (a *KlingAdaptor) Init(info *common.RelayInfo) { a.info = info } -func (a *KlingAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _ []byte) *common.TaskError { +func (a *KlingAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body"} + } + if _, ok := req["model"]; !ok { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"} + } return nil } func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 { ratios := map[string]float64{"base": 1.0} var req map[string]any - if json.Unmarshal(body, &req) == nil { - // 高清模式加价 - if mode, ok := req["mode"]; ok && fmt.Sprintf("%v", mode) == "hq" { - ratios["quality"] = 2.0 + if json.Unmarshal(body, &req) != nil { + return ratios + } + + // 高清模式加价 + if mode, ok := req["mode"].(string); ok && mode == "pro" { + ratios["quality"] = 3.5 + } + + // 时长加价:10s = 2x + if metadata, ok := req["metadata"].(map[string]any); ok { + if v, ok := metadata["duration"].(float64); ok && v >= 10 { + ratios["duration_multiplier"] = 2.0 } } + if seconds, ok := req["seconds"].(string); ok { + if d, err := parseInt(seconds); err == nil && d >= 10 { + ratios["duration_multiplier"] = 2.0 + } + } + return ratios } @@ -49,20 +134,47 @@ func (a *KlingAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[ return nil } +// determineTaskType 根据请求内容判断任务类型 +func determineTaskType(req map[string]any) string { + // Omni-Video 模型 + if modelName, ok := req["model"].(string); ok { + if strings.HasPrefix(modelName, "kling-video-o") || strings.HasPrefix(modelName, "kling-v3") { + return "omni-video" + } + } + // 有图片输入则 image2video + if metadata, ok := req["metadata"].(map[string]any); ok { + if _, hasImg := metadata["image"]; hasImg { + return "image2video" + } + } + if _, hasImg := req["images"].([]any); hasImg { + return "image2video" + } + return "text2video" +} + func (a *KlingAdaptor) BuildRequestURL(info *common.RelayInfo) (string, error) { baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/") - // 根据是否有 image 参数决定 text2video 或 image2video - // 默认使用 text2video + // 根据 model 名判断路径(URL 在 BuildRequestBody 阶段确定 taskType,此处用 model 前置判断) + modelName := info.ChannelMeta.UpstreamModelName + if modelName == "" { + modelName = info.OriginModelName + } + + if strings.HasPrefix(modelName, "kling-video-o") || strings.HasPrefix(modelName, "kling-v3") { + return fmt.Sprintf("%s/v1/videos/omni-video", baseURL), nil + } + + // 默认 text2video,image2video 由 BuildRequestBody 设置 task_type 后 FetchTask 使用 return fmt.Sprintf("%s/v1/videos/text2video", baseURL), nil } func (a *KlingAdaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error { apiKey := info.ChannelMeta.ApiKey - // 如果是 sk- 开头的 key,直接用 Bearer token if strings.HasPrefix(apiKey, "sk-") { header.Set("Authorization", "Bearer "+apiKey) } else { - // 否则生成 JWT token(accessKey|secretKey 格式) token := buildKlingJWT(apiKey) header.Set("Authorization", "Bearer "+token) } @@ -76,34 +188,107 @@ func (a *KlingAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInf return strings.NewReader(string(body)), nil } - if info.ChannelMeta.UpstreamModelName != "" { - req["model_name"] = info.ChannelMeta.UpstreamModelName - req["model"] = info.ChannelMeta.UpstreamModelName + taskType := determineTaskType(req) + kReq := klingRequest{} + + // 模型名 + if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" { + kReq.ModelName = info.ChannelMeta.UpstreamModelName + } else if m, ok := req["model"].(string); ok { + kReq.ModelName = m } - // 如果没有指定 mode,默认 std - if _, ok := req["mode"]; !ok { - req["mode"] = "std" - } - // 如果没有指定 duration,默认 5 - if _, ok := req["duration"]; !ok { - req["duration"] = "5" + // prompt + if v, ok := req["prompt"].(string); ok { + kReq.Prompt = v } - data, _ := json.Marshal(req) + // 从 metadata 提取参数 + if metadata, ok := req["metadata"].(map[string]any); ok { + if v, ok := metadata["negative_prompt"].(string); ok { + kReq.NegativePrompt = v + } + if v, ok := metadata["mode"].(string); ok { + kReq.Mode = v + } + if v, ok := metadata["duration"].(string); ok { + kReq.Duration = v + } else if v, ok := metadata["duration"].(float64); ok { + kReq.Duration = fmt.Sprintf("%d", int(v)) + } + if v, ok := metadata["aspect_ratio"].(string); ok { + kReq.AspectRatio = v + } + if v, ok := metadata["cfg_scale"].(float64); ok { + kReq.CfgScale = &v + } + if v, ok := metadata["image"].(string); ok { + kReq.Image = v + } + if v, ok := metadata["image_tail"].(string); ok { + kReq.ImageTail = v + } + if v, ok := metadata["sound"].(string); ok { + kReq.Sound = v + } + if v, ok := metadata["camera_control"].(map[string]any); ok { + kReq.CameraControl = v + } + } + + // seconds 字段映射到 duration + if seconds, ok := req["seconds"].(string); ok { + kReq.Duration = seconds + } + + // images 数组 → 取第一张作为 image + if images, ok := req["images"].([]any); ok && len(images) > 0 { + if url, ok := images[0].(string); ok && kReq.Image == "" { + kReq.Image = url + } + } + + // 默认值 + if kReq.Mode == "" { + kReq.Mode = "std" + } + if kReq.Duration == "" { + kReq.Duration = "5" + } + + // 记录 taskType 供 DoRequest 使用正确的 URL + if taskType == "image2video" || taskType == "omni-video" { + a.taskType = taskType + } + + data, err := json.Marshal(kReq) + if err != nil { + return nil, fmt.Errorf("marshal kling request: %w", err) + } return strings.NewReader(string(data)), nil } func (a *KlingAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { - url, err := a.BuildRequestURL(info) - if err != nil { - return nil, err + baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/") + + // 根据 taskType 决定 URL + var reqURL string + switch a.taskType { + case "image2video": + reqURL = fmt.Sprintf("%s/v1/videos/image2video", baseURL) + case "omni-video": + reqURL = fmt.Sprintf("%s/v1/videos/omni-video", baseURL) + default: + reqURL = fmt.Sprintf("%s/v1/videos/text2video", baseURL) } - req, err := http.NewRequest("POST", url, requestBody) + + req, err := http.NewRequest(http.MethodPost, reqURL, requestBody) if err != nil { - return nil, err + return nil, fmt.Errorf("create request: %w", err) + } + if err := a.BuildRequestHeader(req.Header, info); err != nil { + return nil, fmt.Errorf("setup header: %w", err) } - a.BuildRequestHeader(req.Header, info) client := &http.Client{Timeout: 120 * 1e9} return client.Do(req) } @@ -111,19 +296,14 @@ func (a *KlingAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requ func (a *KlingAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common.RelayInfo) (string, []byte, *common.TaskError) { body, err := io.ReadAll(resp.Body) if err != nil { - return "", nil, &common.TaskError{StatusCode: 500, Message: "read response failed"} + return "", nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read response failed"} } - var result struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - TaskID string `json:"task_id"` - } `json:"data"` - } + var result klingSubmitResponse if err := json.Unmarshal(body, &result); err != nil { - return "", body, &common.TaskError{StatusCode: 500, Message: "parse response failed"} + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse response failed"} } + if result.Code != 0 { return "", body, &common.TaskError{ StatusCode: resp.StatusCode, @@ -131,16 +311,18 @@ func (a *KlingAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *com ErrCode: fmt.Sprintf("%d", result.Code), } } + if result.Data.TaskID == "" { - return "", body, &common.TaskError{StatusCode: 500, Message: "upstream returned empty task id"} + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "upstream returned empty task id"} } + return result.Data.TaskID, body, nil } func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { var data struct { TaskID string `json:"task_id"` - TaskType string `json:"task_type"` // text2video 或 image2video + TaskType string `json:"task_type"` } if err := json.Unmarshal(taskData, &data); err != nil { return nil, fmt.Errorf("kling: invalid task data: %w", err) @@ -150,9 +332,10 @@ func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http if taskType == "" { taskType = "text2video" } + url := fmt.Sprintf("%s/v1/videos/%s/%s", strings.TrimRight(baseURL, "/"), taskType, data.TaskID) - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } @@ -162,27 +345,14 @@ func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http } else { req.Header.Set("Authorization", "Bearer "+buildKlingJWT(apiKey)) } + req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * 1e9} return client.Do(req) } func (a *KlingAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) { - var resp struct { - Code int `json:"code"` - Message string `json:"message"` - Data struct { - TaskStatus string `json:"task_status"` - TaskStatusMsg string `json:"task_status_msg"` - TaskResult struct { - Videos []struct { - ID string `json:"id"` - URL string `json:"url"` - Duration string `json:"duration"` - } `json:"videos"` - } `json:"task_result"` - } `json:"data"` - } + var resp klingTaskResponse if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("kling: parse task result: %w", err) } @@ -198,6 +368,7 @@ func (a *KlingAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) { switch resp.Data.TaskStatus { case "submitted": info.Status = common.TaskStatusSubmitted + info.Progress = "10%" case "processing": info.Status = common.TaskStatusInProgress info.Progress = "50%" @@ -212,15 +383,24 @@ func (a *KlingAdaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) { info.FailReason = resp.Data.TaskStatusMsg default: info.Status = common.TaskStatusSubmitted + info.Progress = "10%" } return info, nil } func (a *KlingAdaptor) GetModelList() []string { - return []string{"kling-v1", "kling-v1-6", "kling-v2-master"} + return ModelList } func (a *KlingAdaptor) GetChannelName() string { return channelName } + +// ==================== 辅助函数 ==================== + +func parseInt(s string) (int, error) { + var n int + _, err := fmt.Sscanf(s, "%d", &n) + return n, err +}