From 69c2dc4a6132663bc31fab7ce0c67c19dec68cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Fri, 10 Jul 2026 14:50:58 +0800 Subject: [PATCH] 1 --- shortdrama/model/adapter/video.go | 109 ----------- shortdrama/model/adapter/video_dashscope.go | 197 ------------------- shortdrama/model/adapter/video_volcano.go | 193 ------------------ shortdrama/service/drama_service.go | 207 ++++++++++++++++---- 4 files changed, 164 insertions(+), 542 deletions(-) delete mode 100644 shortdrama/model/adapter/video.go delete mode 100644 shortdrama/model/adapter/video_dashscope.go delete mode 100644 shortdrama/model/adapter/video_volcano.go diff --git a/shortdrama/model/adapter/video.go b/shortdrama/model/adapter/video.go deleted file mode 100644 index 743c8d6..0000000 --- a/shortdrama/model/adapter/video.go +++ /dev/null @@ -1,109 +0,0 @@ -package adapter - -import ( - "context" - "strings" -) - -// ==================== Provider Types ==================== - -// VideoProvider 视频生成模型供应商 -type VideoProvider string - -const ( - VideoProviderDashScope VideoProvider = "dashscope" // 通义万相(阿里云百炼) - VideoProviderVolcano VideoProvider = "volcano" // 火山引擎(豆包视频生成) - VideoProviderKling VideoProvider = "kling" // 可灵(快手) - VideoProviderRunway VideoProvider = "runway" // Runway Gen-3/Gen-4 - VideoProviderPika VideoProvider = "pika" // Pika - VideoProviderSora VideoProvider = "sora" // OpenAI Sora -) - -// ==================== Unified Types ==================== - -// VideoSubmitReq 视频生成提交请求(统一格式,适配器内部转成各供应商实际格式) -type VideoSubmitReq struct { - Prompt string // 视频描述 prompt - ImageURLs []string // 参考图片 URL 列表(首帧图放第一个) - Duration int // 期望时长(秒) - Size string // 分辨率,格式 "W*H",如 "720*1280" - ModelName string // 模型名称 - Extra map[string]any // 扩展参数(供应商特有) -} - -// VideoSubmitRes 视频生成提交响应 -type VideoSubmitRes struct { - TaskID string // 异步任务 ID - Extra map[string]any // 扩展信息 -} - -// VideoQueryRes 视频生成状态查询响应 -type VideoQueryRes struct { - Status VideoTaskStatus // 任务状态 - VideoURL string // 视频下载地址(完成时非空) - ErrorMsg string // 错误信息(失败时非空) - Extra map[string]any // 扩展信息 -} - -// VideoTaskStatus 视频任务状态 -type VideoTaskStatus string - -const ( - VideoTaskPending VideoTaskStatus = "PENDING" // 排队中 - VideoTaskRunning VideoTaskStatus = "RUNNING" // 生成中 - VideoTaskSucceeded VideoTaskStatus = "SUCCEEDED" // 已完成 - VideoTaskFailed VideoTaskStatus = "FAILED" // 失败 - VideoTaskUnknown VideoTaskStatus = "UNKNOWN" // 未知状态 -) - -// VideoAdapter 视频生成模型适配器接口 -type VideoAdapter interface { - // Submit 提交视频生成任务 - Submit(ctx context.Context, req *VideoSubmitReq) (*VideoSubmitRes, error) - // Query 查询视频生成任务状态 - Query(ctx context.Context, taskID string) (*VideoQueryRes, error) -} - -// ==================== Size Helpers ==================== - -// ParseVideoSize 解析 "W*H" 格式的分辨率为宽高,返回(宽,高) -func ParseVideoSize(size string) (width, height int) { - if size == "" { - return 0, 0 - } - parts := strings.Split(size, "*") - if len(parts) != 2 { - return 0, 0 - } - w, _ := parseInt(parts[0]) - h, _ := parseInt(parts[1]) - return w, h -} - -func parseInt(s string) (int, error) { - var n int - for _, c := range s { - if c >= '0' && c <= '9' { - n = n*10 + int(c-'0') - } else { - return 0, nil - } - } - return n, nil -} - -// ==================== Factory ==================== - -// NewVideoAdapter 根据模型名称自动识别供应商并创建视频模型适配器 -func NewVideoAdapter(apiKey, baseURL, modelName string) VideoAdapter { - modelLower := strings.ToLower(modelName) - baseURLLower := strings.ToLower(baseURL) - - // 火山引擎(豆包视频生成) - if strings.Contains(modelLower, "doubao") || strings.Contains(modelLower, "seedo") || - strings.Contains(baseURLLower, "volc") || strings.Contains(baseURLLower, "volcano") { - return NewVolcanoAdapter(apiKey, baseURL, modelName) - } - // 默认使用通义万相(阿里云百炼) - return NewDashScopeAdapter(apiKey, baseURL, modelName) -} diff --git a/shortdrama/model/adapter/video_dashscope.go b/shortdrama/model/adapter/video_dashscope.go deleted file mode 100644 index 4c2ba6e..0000000 --- a/shortdrama/model/adapter/video_dashscope.go +++ /dev/null @@ -1,197 +0,0 @@ -package adapter - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -// ==================== DashScope Adapter ==================== - -// dashScopeAdapter 通义万相(阿里云百炼/DashScope)视频生成适配器 -type dashScopeAdapter struct { - apiKey string - baseURL string - modelName string - httpClient *http.Client -} - -// NewDashScopeAdapter 创建 DashScope 视频适配器 -func NewDashScopeAdapter(apiKey, baseURL, modelName string) VideoAdapter { - return &dashScopeAdapter{ - apiKey: apiKey, - baseURL: baseURL, - modelName: modelName, - httpClient: &http.Client{Timeout: 30 * time.Second}, - } -} - -// dashScopeSubmitReq DashScope 视频提交请求格式 -type dashScopeSubmitReq struct { - Model string `json:"model"` - Input dashScopeSubmitInput `json:"input"` - Parameters dashScopeSubmitParams `json:"parameters,omitempty"` -} - -type dashScopeSubmitInput struct { - Prompt string `json:"prompt"` - Images []string `json:"images,omitempty"` -} - -type dashScopeSubmitParams struct { - Size string `json:"size,omitempty"` - Duration int `json:"duration,omitempty"` -} - -// dashScopeSubmitResp DashScope 视频提交响应格式 -type dashScopeSubmitResp struct { - Output struct { - TaskID string `json:"task_id"` - } `json:"output"` - Code string `json:"code"` - Message string `json:"message"` -} - -// dashScopeQueryResp DashScope 视频查询响应格式 -type dashScopeQueryResp struct { - Output struct { - TaskStatus string `json:"task_status"` - VideoURL string `json:"video_url"` - Code string `json:"code"` - Message string `json:"message"` - Results []struct { - VideoURL string `json:"video_url"` - URL string `json:"url"` - } `json:"results"` - } `json:"output"` - Code string `json:"code"` - Message string `json:"message"` -} - -func (a *dashScopeAdapter) Submit(ctx context.Context, req *VideoSubmitReq) (*VideoSubmitRes, error) { - body := dashScopeSubmitReq{ - Model: a.modelName, - Input: dashScopeSubmitInput{ - Prompt: req.Prompt, - }, - } - - if len(req.ImageURLs) > 0 { - body.Input.Images = req.ImageURLs - } - - hasDuration := req.Duration > 0 - hasSize := req.Size != "" - if hasDuration || hasSize { - body.Parameters = dashScopeSubmitParams{} - if hasSize { - body.Parameters.Size = req.Size - } - if hasDuration { - body.Parameters.Duration = req.Duration - } - } - - payload, _ := json.Marshal(body) - httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL, bytes.NewBuffer(payload)) - if err != nil { - return nil, fmt.Errorf("创建请求失败: %w", err) - } - httpReq.Header.Set("Authorization", "Bearer "+a.apiKey) - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("X-DashScope-Async", "enable") - - resp, err := a.httpClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("请求失败: %w", err) - } - defer resp.Body.Close() - - data, _ := io.ReadAll(resp.Body) - var result dashScopeSubmitResp - if err := json.Unmarshal(data, &result); err != nil { - return nil, fmt.Errorf("解析响应失败: %s", string(data)) - } - - if result.Code != "" { - return nil, fmt.Errorf("请求失败(code=%s): %s", result.Code, string(data)) - } - if result.Output.TaskID == "" { - return nil, fmt.Errorf("任务ID为空") - } - - return &VideoSubmitRes{ - TaskID: result.Output.TaskID, - }, nil -} - -func (a *dashScopeAdapter) Query(ctx context.Context, taskID string) (*VideoQueryRes, error) { - queryURL := a.baseURL + "/" + taskID // DashScope 查询格式:baseURL/task_id - - httpReq, err := http.NewRequestWithContext(ctx, "GET", queryURL, nil) - if err != nil { - return nil, fmt.Errorf("创建查询请求失败: %w", err) - } - httpReq.Header.Set("Authorization", "Bearer "+a.apiKey) - httpReq.Header.Set("Content-Type", "application/json") - - resp, err := a.httpClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("查询请求失败: %w", err) - } - defer resp.Body.Close() - - data, _ := io.ReadAll(resp.Body) - var result dashScopeQueryResp - if err := json.Unmarshal(data, &result); err != nil { - return nil, fmt.Errorf("解析查询响应失败: %s", string(data)) - } - - // 映射状态 - status := mapDashScopeStatus(result.Output.TaskStatus) - - // 提取视频 URL - videoURL := result.Output.VideoURL - if videoURL == "" && len(result.Output.Results) > 0 { - videoURL = result.Output.Results[0].VideoURL - if videoURL == "" { - videoURL = result.Output.Results[0].URL - } - } - - errMsg := "" - if status == VideoTaskFailed || (result.Output.Code != "" || result.Output.Message != "") { - errMsg = result.Output.TaskStatus - if result.Output.Code != "" || result.Output.Message != "" { - errMsg = fmt.Sprintf("%s(code=%s, msg=%s)", result.Output.TaskStatus, result.Output.Code, result.Output.Message) - } else if result.Code != "" || result.Message != "" { - errMsg = fmt.Sprintf("%s(code=%s, msg=%s)", result.Output.TaskStatus, result.Code, result.Message) - } - } - - return &VideoQueryRes{ - Status: status, - VideoURL: videoURL, - ErrorMsg: errMsg, - }, nil -} - -// mapDashScopeStatus 将 DashScope 任务状态映射为统一状态 -func mapDashScopeStatus(s string) VideoTaskStatus { - switch s { - case "PENDING": - return VideoTaskPending - case "RUNNING": - return VideoTaskRunning - case "SUCCEEDED": - return VideoTaskSucceeded - case "FAILED": - return VideoTaskFailed - default: - return VideoTaskUnknown - } -} diff --git a/shortdrama/model/adapter/video_volcano.go b/shortdrama/model/adapter/video_volcano.go deleted file mode 100644 index c9815d0..0000000 --- a/shortdrama/model/adapter/video_volcano.go +++ /dev/null @@ -1,193 +0,0 @@ -package adapter - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" -) - -// ==================== Volcano Engine (火山引擎) Adapter ==================== - -// volcanoAdapter 火山引擎(豆包视频生成)适配器 -// API 文档:https://www.volcengine.com/docs/6791/1397048 -type volcanoAdapter struct { - apiKey string - baseURL string - modelName string - httpClient *http.Client -} - -// NewVolcanoAdapter 创建火山引擎视频适配器 -func NewVolcanoAdapter(apiKey, baseURL, modelName string) VideoAdapter { - return &volcanoAdapter{ - apiKey: apiKey, - baseURL: baseURL, - modelName: modelName, - httpClient: &http.Client{Timeout: 30 * time.Second}, - } -} - -// volcanoSubmitReq 火山引擎视频提交请求格式 -type volcanoSubmitReq struct { - Model string `json:"model"` - Content volcanoContent `json:"content,omitempty"` - Params map[string]any `json:"parameters,omitempty"` -} - -type volcanoContent struct { - Prompt string `json:"prompt"` - Duration int `json:"duration,omitempty"` - Images []string `json:"images,omitempty"` -} - -// volcanoSubmitResp 火山引擎视频提交响应格式 -type volcanoSubmitResp struct { - ID string `json:"id"` - Code int `json:"code"` - Msg string `json:"msg"` - Result *struct { - ID string `json:"id"` - } `json:"result"` -} - -// volcanoQueryResp 火山引擎视频查询响应格式 -type volcanoQueryResp struct { - Code int `json:"code"` - Msg string `json:"msg"` - Result *volcanoQueryResult `json:"result"` -} - -type volcanoQueryResult struct { - Status string `json:"status"` // "running" | "succeeded" | "failed" - VideoURL string `json:"video_url"` - VideoUrl string `json:"videoUrl"` // 驼峰格式兼容 - ErrorMessage string `json:"error_message"` - ErrorMsg string `json:"error_msg"` - Progress int `json:"progress"` -} - -func (a *volcanoAdapter) Submit(ctx context.Context, req *VideoSubmitReq) (*VideoSubmitRes, error) { - body := volcanoSubmitReq{ - Model: a.modelName, - Content: volcanoContent{ - Prompt: req.Prompt, - Duration: req.Duration, - }, - } - if len(req.ImageURLs) > 0 { - body.Content.Images = req.ImageURLs - } - - payload, _ := json.Marshal(body) - httpReq, err := http.NewRequestWithContext(ctx, "POST", a.baseURL, bytes.NewBuffer(payload)) - if err != nil { - return nil, fmt.Errorf("创建请求失败: %w", err) - } - httpReq.Header.Set("Authorization", "Bearer "+a.apiKey) - httpReq.Header.Set("Content-Type", "application/json") - - resp, err := a.httpClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("请求失败: %w", err) - } - defer resp.Body.Close() - - data, _ := io.ReadAll(resp.Body) - var result volcanoSubmitResp - if err := json.Unmarshal(data, &result); err != nil { - return nil, fmt.Errorf("解析响应失败: %s", string(data)) - } - - if result.Code != 0 { - return nil, fmt.Errorf("请求失败(code=%d, msg=%s)", result.Code, result.Msg) - } - - taskID := result.ID - if taskID == "" && result.Result != nil { - taskID = result.Result.ID - } - if taskID == "" { - return nil, fmt.Errorf("任务ID为空") - } - - return &VideoSubmitRes{ - TaskID: taskID, - }, nil -} - -func (a *volcanoAdapter) Query(ctx context.Context, taskID string) (*VideoQueryRes, error) { - // 火山引擎查询支持 path 和 query 两种方式,优先按 path 格式 - queryURL := a.baseURL + "/" + taskID - - httpReq, err := http.NewRequestWithContext(ctx, "GET", queryURL, nil) - if err != nil { - return nil, fmt.Errorf("创建查询请求失败: %w", err) - } - httpReq.Header.Set("Authorization", "Bearer "+a.apiKey) - httpReq.Header.Set("Content-Type", "application/json") - - resp, err := a.httpClient.Do(httpReq) - if err != nil { - return nil, fmt.Errorf("查询请求失败: %w", err) - } - defer resp.Body.Close() - - data, _ := io.ReadAll(resp.Body) - var result volcanoQueryResp - if err := json.Unmarshal(data, &result); err != nil { - return nil, fmt.Errorf("解析查询响应失败: %s", string(data)) - } - - if result.Code != 0 { - return &VideoQueryRes{ - Status: VideoTaskFailed, - ErrorMsg: fmt.Sprintf("查询失败(code=%d, msg=%s)", result.Code, result.Msg), - }, nil - } - - if result.Result == nil { - return &VideoQueryRes{ - Status: VideoTaskRunning, - }, nil - } - - // 映射状态 - status := mapVolcanoStatus(result.Result.Status) - - // 提取视频 URL - videoURL := result.Result.VideoURL - if videoURL == "" { - videoURL = result.Result.VideoUrl - } - - errMsg := result.Result.ErrorMessage - if errMsg == "" { - errMsg = result.Result.ErrorMsg - } - - return &VideoQueryRes{ - Status: status, - VideoURL: videoURL, - ErrorMsg: errMsg, - }, nil -} - -// mapVolcanoStatus 将火山引擎任务状态映射为统一状态 -func mapVolcanoStatus(s string) VideoTaskStatus { - switch s { - case "pending", "queued": - return VideoTaskPending - case "running", "processing": - return VideoTaskRunning - case "succeeded", "success", "done": - return VideoTaskSucceeded - case "failed", "error": - return VideoTaskFailed - default: - return VideoTaskUnknown - } -} diff --git a/shortdrama/service/drama_service.go b/shortdrama/service/drama_service.go index 364023d..d693e40 100644 --- a/shortdrama/service/drama_service.go +++ b/shortdrama/service/drama_service.go @@ -18,7 +18,6 @@ import ( "video-factory/shortdrama/consts/public" "video-factory/shortdrama/dao" "video-factory/shortdrama/model" - "video-factory/shortdrama/model/adapter" "video-factory/shortdrama/model/dto" "video-factory/shortdrama/model/entity" @@ -37,6 +36,17 @@ const ( DefaultFirstFramePath = "default_first_frame.png" // 图生视频默认首帧图片路径 ) +// 视频任务状态 +type videoTaskStatus string + +const ( + videoTaskPending videoTaskStatus = "PENDING" + videoTaskRunning videoTaskStatus = "RUNNING" + videoTaskSucceeded videoTaskStatus = "SUCCEEDED" + videoTaskFailed videoTaskStatus = "FAILED" + videoTaskUnknown videoTaskStatus = "UNKNOWN" +) + func (s *dramaService) Create(ctx context.Context, title, contentType, config, aspectRatio string, episodeDuration int64, minShotDuration, maxShotDuration int, resolution string) (int64, error) { existing, _ := dao.Drama.GetByTitle(ctx, title) if existing != nil { @@ -963,47 +973,95 @@ func (s *dramaService) pollPendingVideos(ctx context.Context) { } -// pollVideoTaskOnce 单次查询视频任务状态(使用视频适配器) +// pollVideoTaskOnce 单次查询视频生成任务状态 func (s *dramaService) pollVideoTaskOnce(ctx context.Context, modelCfg *entity.ModelConfig, taskId string) (string, error) { - videoAdapter := adapter.NewVideoAdapter(modelCfg.VideoApiKey, modelCfg.VideoBaseUrl, modelCfg.VideoModelName) + queryURL := strings.TrimRight(modelCfg.VideoBaseUrl, "/") + "/" + taskId - res, err := videoAdapter.Query(ctx, taskId) + req, err := http.NewRequestWithContext(ctx, "GET", queryURL, nil) if err != nil { - return "", err + return "", fmt.Errorf("创建查询请求失败: %w", err) + } + req.Header.Set("Authorization", "Bearer "+modelCfg.VideoApiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("查询请求失败: %w", err) + } + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + var result struct { + Output struct { + TaskStatus string `json:"task_status"` + VideoURL string `json:"video_url"` + Code string `json:"code"` + Message string `json:"message"` + Results []struct { + VideoURL string `json:"video_url"` + URL string `json:"url"` + } `json:"results"` + } `json:"output"` + } + if err := json.Unmarshal(data, &result); err != nil { + return "", fmt.Errorf("解析响应失败: %s", string(data)) } - if res.Status == adapter.VideoTaskSucceeded && res.VideoURL != "" { - return res.VideoURL, nil + videoURL := result.Output.VideoURL + if videoURL == "" && len(result.Output.Results) > 0 { + videoURL = result.Output.Results[0].VideoURL + if videoURL == "" { + videoURL = result.Output.Results[0].URL + } } - if res.Status == adapter.VideoTaskFailed { - return "", fmt.Errorf("FAILED: %s", res.ErrorMsg) + status := mapVideoTaskStatus(result.Output.TaskStatus) + buildErrMsg := func() string { + c, m := result.Output.Code, result.Output.Message + if c != "" || m != "" { + return fmt.Sprintf("%s(code=%s, msg=%s)", result.Output.TaskStatus, c, m) + } + return result.Output.TaskStatus } - if res.Status == adapter.VideoTaskRunning || res.Status == adapter.VideoTaskPending { + switch status { + case videoTaskSucceeded: + if videoURL != "" { + return videoURL, nil + } + case videoTaskFailed: + return "", fmt.Errorf("FAILED: %s", buildErrMsg()) + case videoTaskRunning, videoTaskPending: return "", fmt.Errorf("RUNNING") } - // 未知状态但返回了错误消息 - if res.ErrorMsg != "" { - return "", fmt.Errorf("任务状态: %s", res.ErrorMsg) + errMsg := buildErrMsg() + if errMsg != result.Output.TaskStatus { + return "", fmt.Errorf("任务状态: %s", errMsg) } - return "", fmt.Errorf("视频URL为空") } +// mapVideoTaskStatus 将供应商任务状态映射为内部状态 +func mapVideoTaskStatus(s string) videoTaskStatus { + switch s { + case "PENDING": + return videoTaskPending + case "RUNNING": + return videoTaskRunning + case "SUCCEEDED": + return videoTaskSucceeded + case "FAILED": + return videoTaskFailed + default: + return videoTaskUnknown + } +} + // ==================== Video Generation ==================== -// getVideoAdapter 根据模型配置获取视频适配器 -func getVideoAdapter(ctx context.Context) (adapter.VideoAdapter, error) { - modelCfg := ConfigService.Get(ctx) - if modelCfg.VideoApiKey == "" || modelCfg.VideoBaseUrl == "" || modelCfg.VideoModelName == "" { - return nil, fmt.Errorf("视频模型未配置") - } - return adapter.NewVideoAdapter(modelCfg.VideoApiKey, modelCfg.VideoBaseUrl, modelCfg.VideoModelName), nil -} - -// submitVideoTask 提交视频合成任务(使用视频适配器) +// submitVideoTask 提交视频合成任务 func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep *entity.Episode, segIdx, segDur int, scenes []model.SegmentScene, refs []model.VideoRef) (string, error) { modelCfg := ConfigService.Get(ctx) if modelCfg.VideoApiKey == "" || modelCfg.VideoBaseUrl == "" || modelCfg.VideoModelName == "" { @@ -1061,37 +1119,100 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep g.Log().Infof(ctx, "prompt超长已截断至%d字符(原%d字符)", maxInputLen, len([]rune(prompt))) } - videoAdapter, err := getVideoAdapter(ctx) + taskId, err := createVideoTask(ctx, modelCfg.VideoApiKey, modelCfg.VideoBaseUrl, modelCfg.VideoModelName, + prompt, refImages, segDur, resolveVideoSize(d.Resolution, d.AspectRatio)) if err != nil { - return "", err - } - - submitReq := &adapter.VideoSubmitReq{ - Prompt: prompt, - ImageURLs: refImages, - Duration: segDur, - Size: resolveVideoSize(d.Resolution, d.AspectRatio), - ModelName: modelCfg.VideoModelName, - } - - submitRes, err := videoAdapter.Submit(ctx, submitReq) - if err != nil { - // 部分模型不支持自定义 duration,去掉后重试一次 errStr := strings.ToLower(err.Error()) if strings.Contains(errStr, "duration") && (strings.Contains(errStr, "not support") || strings.Contains(errStr, "not supported")) { - submitReq.Duration = 0 - submitRes, err = videoAdapter.Submit(ctx, submitReq) + taskId, err = createVideoTask(ctx, modelCfg.VideoApiKey, modelCfg.VideoBaseUrl, modelCfg.VideoModelName, + prompt, refImages, 0, resolveVideoSize(d.Resolution, d.AspectRatio)) } if err != nil { return "", fmt.Errorf("视频合成请求失败: %w", err) } } - if submitRes == nil || submitRes.TaskID == "" { + if taskId == "" { return "", fmt.Errorf("视频合成任务ID为空") } - return submitRes.TaskID, nil + return taskId, nil +} + +// createVideoTask 调用视频生成API提交任务 +func createVideoTask(ctx context.Context, apiKey, baseURL, modelName, prompt string, images []string, duration int, size string) (string, error) { + var ( + inputImages []string + paramsSize string + paramsDur int + hasParams bool + ) + if len(images) > 0 { + inputImages = images + } + if size != "" { + paramsSize = size + hasParams = true + } + if duration > 0 { + paramsDur = duration + hasParams = true + } + + body := map[string]any{ + "model": modelName, + "input": map[string]any{ + "prompt": prompt, + }, + } + if len(inputImages) > 0 { + body["input"].(map[string]any)["images"] = inputImages + } + if hasParams { + p := map[string]any{} + if paramsSize != "" { + p["size"] = paramsSize + } + if paramsDur > 0 { + p["duration"] = paramsDur + } + body["parameters"] = p + } + + payload, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, "POST", baseURL, strings.NewReader(string(payload))) + if err != nil { + return "", fmt.Errorf("创建请求失败: %w", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-DashScope-Async", "enable") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("请求失败: %w", err) + } + defer resp.Body.Close() + + respData, _ := io.ReadAll(resp.Body) + var result struct { + Output struct { + TaskID string `json:"task_id"` + } `json:"output"` + Code string `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(respData, &result); err != nil { + return "", fmt.Errorf("解析响应失败: %s", string(respData)) + } + if result.Code != "" { + return "", fmt.Errorf("请求失败(code=%s): %s", result.Code, string(respData)) + } + if result.Output.TaskID == "" { + return "", fmt.Errorf("任务ID为空") + } + return result.Output.TaskID, nil } // resolveVideoSize 根据分辨率和宽高比计算视频尺寸