1
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 根据分辨率和宽高比计算视频尺寸
|
||||
|
||||
Reference in New Issue
Block a user