This commit is contained in:
2026-07-10 14:50:58 +08:00
parent dbeea873a7
commit 69c2dc4a61
4 changed files with 164 additions and 542 deletions
-109
View File
@@ -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)
}
-197
View File
@@ -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
}
}
-193
View File
@@ -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
}
}
+164 -43
View File
@@ -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 根据分辨率和宽高比计算视频尺寸