This commit is contained in:
2026-07-09 16:05:48 +08:00
parent d483ab0c75
commit 5aed3e72de
16 changed files with 833 additions and 265 deletions
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.26.1
require (
github.com/cloudwego/eino v0.9.9
github.com/cloudwego/eino-ext/components/model/qwen v0.1.9
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
github.com/gogf/gf/v2 v2.10.2
)
@@ -21,7 +22,6 @@ require (
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eino-contrib/jsonschema v1.0.3 // indirect
+4
View File
@@ -146,6 +146,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qq
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
@@ -186,6 +187,7 @@ go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -196,6 +198,7 @@ golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -207,6 +210,7 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
BIN
View File
Binary file not shown.
+9 -21
View File
@@ -5,13 +5,13 @@ import (
"fmt"
"time"
"github.com/cloudwego/eino-ext/components/model/qwen"
"github.com/cloudwego/eino/components/model"
"github.com/gogf/gf/v2/util/gconv"
"video-factory/shortdrama/model/adapter"
)
// ModelConfig 模型配置 — 所有字段必须显式提供,无硬编码默认值
type ModelConfig struct {
Provider string // 模型供应商(qwen/openai_compatible),空则自动检测
ModelName string // 对话模型名
APIKey string // API密钥
BaseURL string // API地址
@@ -65,7 +65,7 @@ func GetBaseURL(ctx context.Context) string {
return ""
}
// NewChatModel 根据配置初始化聊天模型
// NewChatModel 根据配置初始化聊天模型(使用适配器模式,自动选择供应商)
func NewChatModel(ctx context.Context, cfg *ModelConfig) (cm model.ChatModel, err error) {
if cfg == nil {
return nil, fmt.Errorf("模型配置不能为空")
@@ -80,26 +80,14 @@ func NewChatModel(ctx context.Context, cfg *ModelConfig) (cm model.ChatModel, er
return nil, fmt.Errorf("API 地址未配置")
}
maxTokens := cfg.MaxTokens
if maxTokens <= 0 {
maxTokens = 4096
}
temperature := cfg.Temperature
if temperature <= 0 {
temperature = 0.8
}
config := &qwen.ChatModelConfig{
adapterCfg := &adapter.ChatModelConfig{
Provider: adapter.ChatProvider(cfg.Provider),
ModelName: cfg.ModelName,
APIKey: cfg.APIKey,
Model: cfg.ModelName,
BaseURL: cfg.BaseURL,
MaxTokens: gconv.PtrInt(maxTokens),
Temperature: gconv.PtrFloat32(temperature),
MaxTokens: cfg.MaxTokens,
Temperature: cfg.Temperature,
Timeout: cfg.Timeout,
}
cm, err = qwen.NewChatModel(ctx, config)
if err != nil {
return nil, fmt.Errorf("创建模型失败: %w", err)
}
return cm, nil
return adapter.NewChatModel(ctx, adapterCfg)
}
+2 -1
View File
@@ -29,16 +29,17 @@ func (c *config) Save(ctx context.Context, req *dto.SaveModelConfigReq) (res *st
return nil, fmt.Errorf("单次生成最小时长不能大于最大时长")
}
cfg := &entity.ModelConfig{
ChatProvider: req.ChatProvider,
ChatApiKey: req.ChatApiKey,
ChatBaseUrl: req.ChatBaseUrl,
ChatModelName: req.ChatModelName,
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
ChatSchema: req.ChatSchema,
VideoProvider: req.VideoProvider,
VideoApiKey: req.VideoApiKey,
VideoBaseUrl: req.VideoBaseUrl,
VideoModelName: req.VideoModelName,
VideoQueryUrl: req.VideoQueryUrl,
MinSingleDuration: req.MinSingleDuration,
MaxSingleDuration: req.MaxSingleDuration,
VideoSchema: req.VideoSchema,
+7 -3
View File
@@ -17,18 +17,19 @@ func init() {
ctx := context.Background()
if _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+public.TableNameModelConfig+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_provider TEXT NOT NULL DEFAULT '',
chat_api_key TEXT NOT NULL DEFAULT '',
video_api_key TEXT NOT NULL DEFAULT '',
chat_base_url TEXT NOT NULL DEFAULT '',
chat_model_name TEXT NOT NULL DEFAULT '',
max_tokens INTEGER NOT NULL DEFAULT 4096,
temperature REAL NOT NULL DEFAULT 0.8,
chat_schema TEXT NOT NULL DEFAULT '',
video_provider TEXT NOT NULL DEFAULT '',
video_api_key TEXT NOT NULL DEFAULT '',
video_base_url TEXT NOT NULL DEFAULT '',
video_model_name TEXT NOT NULL DEFAULT '',
video_query_url TEXT NOT NULL DEFAULT '',
max_single_duration INTEGER NOT NULL DEFAULT 15,
min_single_duration INTEGER NOT NULL DEFAULT 5,
chat_schema TEXT NOT NULL DEFAULT '',
video_schema TEXT NOT NULL DEFAULT '',
created_at DATETIME,
updated_at DATETIME
@@ -44,6 +45,7 @@ func init() {
"max_ref_video_count", "max_ref_video_file_size", "max_ref_video_duration", "ref_video_formats",
"max_ref_audio_count", "max_ref_audio_file_size", "max_ref_audio_duration", "ref_audio_formats",
"max_ref_image_file_size", "ref_image_formats",
"video_query_url",
} {
if _, err := g.DB().Exec(ctx, `ALTER TABLE `+public.TableNameModelConfig+` DROP COLUMN `+col); err != nil {
g.Log().Debugf(ctx, "删除孤儿列 %s 失败(可能已删除): %v", col, err)
@@ -51,6 +53,8 @@ func init() {
}
// 迁移:补充新字段
for _, col := range []string{
"chat_provider",
"video_provider",
"chat_schema",
"video_schema",
} {
+81
View File
@@ -0,0 +1,81 @@
package adapter
import (
"context"
"fmt"
"time"
"github.com/cloudwego/eino/components/model"
)
// ==================== Provider Types ====================
// ChatProvider 对话模型供应商
type ChatProvider string
const (
ChatProviderQwen ChatProvider = "qwen" // 通义千问(使用Eino专用适配器)
ChatProviderDeepSeek ChatProvider = "deepseek" // DeepSeekOpenAI兼容协议)
ChatProviderDoubao ChatProvider = "doubao" // 豆包(OpenAI兼容协议)
ChatProviderGLM ChatProvider = "glm" // 智谱GLMOpenAI兼容协议)
ChatProviderErnie ChatProvider = "ernie" // 文心ERNIEOpenAI兼容协议)
ChatProviderOpenAI ChatProvider = "openai" // OpenAIGPT/o1等)
)
// ==================== Config ====================
// ChatModelConfig 对话模型适配器统一配置
type ChatModelConfig struct {
Provider ChatProvider // 模型供应商
ModelName string // 模型名称
APIKey string // API密钥
BaseURL string // API地址
MaxTokens int // 最大Token数
Temperature float32 // 温度参数
Timeout time.Duration // HTTP请求超时(0表示默认)
}
// ==================== Factory ====================
// NewChatModel 根据配置创建对应的对话模型适配器,返回 Eino ChatModel 接口
func NewChatModel(ctx context.Context, cfg *ChatModelConfig) (model.ChatModel, error) {
if cfg == nil {
return nil, fmt.Errorf("模型配置不能为空")
}
if cfg.APIKey == "" {
return nil, fmt.Errorf("APIKey 未配置")
}
if cfg.ModelName == "" {
return nil, fmt.Errorf("模型名称未配置")
}
if cfg.BaseURL == "" {
return nil, fmt.Errorf("API 地址未配置")
}
// 设置默认值
maxTokens := cfg.MaxTokens
if maxTokens <= 0 {
maxTokens = 4096
}
temperature := cfg.Temperature
if temperature <= 0 {
temperature = 0.8
}
adapterCfg := &ChatModelConfig{
Provider: cfg.Provider,
ModelName: cfg.ModelName,
APIKey: cfg.APIKey,
BaseURL: cfg.BaseURL,
MaxTokens: maxTokens,
Temperature: temperature,
Timeout: cfg.Timeout,
}
switch cfg.Provider {
case ChatProviderQwen:
return newQwenChatModel(ctx, adapterCfg)
default:
return newOpenAIChatModel(ctx, adapterCfg)
}
}
+37
View File
@@ -0,0 +1,37 @@
package adapter
import (
"context"
"fmt"
"net/http"
"github.com/cloudwego/eino-ext/libs/acl/openai"
"github.com/cloudwego/eino/components/model"
"github.com/gogf/gf/v2/util/gconv"
)
// newOpenAIChatModel 创建 OpenAI 兼容协议对话模型
// 适用于:DeepSeek、GLM(智谱)、ERNIE(文心)、Moonshot(Kimi)、Minimax、Stepfun(阶跃星辰)、
//
// Baichuan(百川)、GPT系列、Claude系列等所有兼容 OpenAI API 格式的模型
func newOpenAIChatModel(ctx context.Context, cfg *ChatModelConfig) (model.ChatModel, error) {
httpClient := http.DefaultClient
if cfg.Timeout > 0 {
httpClient = &http.Client{Timeout: cfg.Timeout}
}
config := &openai.Config{
APIKey: cfg.APIKey,
Model: cfg.ModelName,
BaseURL: cfg.BaseURL,
HTTPClient: httpClient,
MaxTokens: gconv.PtrInt(cfg.MaxTokens),
Temperature: gconv.PtrFloat32(cfg.Temperature),
}
client, err := openai.NewClient(ctx, config)
if err != nil {
return nil, fmt.Errorf("创建OpenAI兼容模型失败: %w", err)
}
return client, nil
}
+22
View File
@@ -0,0 +1,22 @@
package adapter
import (
"context"
"github.com/cloudwego/eino-ext/components/model/qwen"
"github.com/cloudwego/eino/components/model"
"github.com/gogf/gf/v2/util/gconv"
)
// newQwenChatModel 创建通义千问对话模型(使用 Eino 专用适配器)
func newQwenChatModel(ctx context.Context, cfg *ChatModelConfig) (model.ChatModel, error) {
config := &qwen.ChatModelConfig{
APIKey: cfg.APIKey,
Model: cfg.ModelName,
BaseURL: cfg.BaseURL,
MaxTokens: gconv.PtrInt(cfg.MaxTokens),
Temperature: gconv.PtrFloat32(cfg.Temperature),
Timeout: cfg.Timeout,
}
return qwen.NewChatModel(ctx, config)
}
+114
View File
@@ -0,0 +1,114 @@
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(provider VideoProvider, apiKey, baseURL, modelName string) VideoAdapter {
switch provider {
case VideoProviderDashScope:
return NewDashScopeAdapter(apiKey, baseURL, modelName)
case VideoProviderVolcano:
return NewVolcanoAdapter(apiKey, baseURL, modelName)
// 后续可在此添加:
// case VideoProviderKling:
// return NewKlingAdapter(apiKey, baseURL, modelName)
// case VideoProviderRunway:
// return NewRunwayAdapter(apiKey, baseURL, modelName)
// case VideoProviderPika:
// return NewPikaAdapter(apiKey, baseURL, modelName)
default:
return NewDashScopeAdapter(apiKey, baseURL, modelName)
}
}
+197
View File
@@ -0,0 +1,197 @@
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
@@ -0,0 +1,193 @@
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
}
}
+3 -2
View File
@@ -16,17 +16,18 @@ type GetModelConfigRes struct {
type SaveModelConfigReq struct {
g.Meta `path:"/model" method:"post" tags:"模型配置" summary:"保存模型配置"`
ChatProvider string `json:"chatProvider" dc:"对话模型供应商(qwen/deepseek/doubao/glm/ernie/openai)"`
ChatApiKey string `v:"required" json:"chatApiKey" dc:"对话模型API密钥"`
ChatBaseUrl string `v:"required|url" json:"chatBaseUrl" dc:"对话模型API接口地址"`
ChatModelName string `v:"required" json:"chatModelName" dc:"对话模型名称"`
MaxTokens int `v:"required" json:"maxTokens" dc:"最大Token数"`
Temperature float64 `v:"required" json:"temperature" dc:"温度参数"`
ChatSchema string `json:"chatSchema" dc:"对话模型schema"`
VideoProvider string `json:"videoProvider" dc:"视频模型供应商(dashscope/kling/runway),空则自动检测"`
VideoApiKey string `v:"required" json:"videoApiKey" dc:"视频模型API密钥"`
VideoBaseUrl string `v:"required|url" json:"videoBaseUrl" dc:"视频模型API接口地址"`
VideoModelName string `v:"required" json:"videoModelName" dc:"视频模型名称"`
VideoQueryUrl string `v:"required" json:"videoQueryUrl" dc:"视频查询接口地址"`
MaxSingleDuration int `v:"required" json:"maxSingleDuration" dc:"单段最大时长"`
MinSingleDuration int `v:"required" json:"minSingleDuration" dc:"单段最小时长"`
ChatSchema string `json:"chatSchema" dc:"对话模型schema"`
VideoSchema string `json:"videoSchema" dc:"视频生成模型schema"`
}
+4 -3
View File
@@ -6,18 +6,19 @@ import (
type ModelConfig struct {
Id int64 `orm:"id" json:"id" dc:"配置ID"`
ChatProvider string `orm:"chat_provider" json:"chatProvider" dc:"对话模型供应商(qwen/deepseek/doubao/glm/ernie/openai)"`
ChatApiKey string `orm:"chat_api_key" json:"chatApiKey" dc:"对话模型API密钥"`
VideoApiKey string `orm:"video_api_key" json:"videoApiKey" dc:"视频模型API密钥"`
ChatBaseUrl string `orm:"chat_base_url" json:"chatBaseUrl" dc:"对话模型接口地址"`
ChatModelName string `orm:"chat_model_name" json:"chatModelName" dc:"对话模型名称"`
MaxTokens int `orm:"max_tokens" json:"maxTokens" dc:"最大Token数"`
Temperature float64 `orm:"temperature" json:"temperature" dc:"温度参数"`
ChatSchema string `orm:"chat_schema" json:"chatSchema" dc:"对话模型schema(JSON格式)"`
VideoProvider string `orm:"video_provider" json:"videoProvider" dc:"视频模型供应商(dashscope/kling/runway)"`
VideoApiKey string `orm:"video_api_key" json:"videoApiKey" dc:"视频模型API密钥"`
VideoBaseUrl string `orm:"video_base_url" json:"videoBaseUrl" dc:"视频模型接口地址"`
VideoModelName string `orm:"video_model_name" json:"videoModelName" dc:"视频模型名称"`
VideoQueryUrl string `orm:"video_query_url" json:"videoQueryUrl" dc:"视频查询接口地址"`
MaxSingleDuration int `orm:"max_single_duration" json:"maxSingleDuration" dc:"单段最大时长"`
MinSingleDuration int `orm:"min_single_duration" json:"minSingleDuration" dc:"单段最小时长"`
ChatSchema string `orm:"chat_schema" json:"chatSchema" dc:"对话模型schema(JSON格式)"`
VideoSchema string `orm:"video_schema" json:"videoSchema" dc:"视频生成模型schema(JSON格式)"`
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间"`
+76 -84
View File
@@ -3,10 +3,8 @@ package service
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"video-factory/shortdrama/dao"
@@ -40,22 +38,28 @@ func (s *configService) Get(ctx context.Context) *entity.ModelConfig {
return m
}
// Save 保存模型配置:先合并用户输入,再查询官方API获取推荐参数,最后统一写入数据库
// Save 保存模型配置:先合并用户输入,再统一写入数据库
func (s *configService) Save(ctx context.Context, cfg *entity.ModelConfig) error {
if existing, _ := dao.ModelConfig.GetFirst(ctx); existing != nil {
// 已有配置:把现有值作为默认值填充到 cfg,当前请求携带的值优先
if cfg.ChatProvider == "" {
cfg.ChatProvider = existing.ChatProvider
}
if cfg.ChatApiKey == "" {
cfg.ChatApiKey = existing.ChatApiKey
}
if cfg.VideoApiKey == "" {
cfg.VideoApiKey = existing.VideoApiKey
}
if cfg.ChatBaseUrl == "" {
cfg.ChatBaseUrl = existing.ChatBaseUrl
}
if cfg.ChatModelName == "" {
cfg.ChatModelName = existing.ChatModelName
}
if cfg.VideoProvider == "" {
cfg.VideoProvider = existing.VideoProvider
}
if cfg.VideoApiKey == "" {
cfg.VideoApiKey = existing.VideoApiKey
}
if cfg.VideoBaseUrl == "" {
cfg.VideoBaseUrl = existing.VideoBaseUrl
}
@@ -76,7 +80,7 @@ func (s *configService) Save(ctx context.Context, cfg *entity.ModelConfig) error
}
}
// 查询官方API获取推荐参数(直接在 cfg 上修改)
// 查询官方API获取推荐参数(直接在 cfg 上修改)
syncModelDurationFromAPI(ctx, cfg)
// 统一写入数据库
@@ -87,25 +91,14 @@ func (s *configService) Save(ctx context.Context, cfg *entity.ModelConfig) error
return nil
}
// syncModelDurationFromAPI 查询阿里云百炼官方API,获取模型支持的参数和时长范围
// syncModelDurationFromAPI 查询模型API,获取模型支持的参数和时长范围
// 并自动更新模型配置中的各项参数
// API 文档:https://help.aliyun.com/zh/model-studio/
func syncModelDurationFromAPI(ctx context.Context, cfg *entity.ModelConfig) {
if cfg.VideoApiKey == "" || cfg.VideoBaseUrl == "" || cfg.VideoModelName == "" {
return
}
// 从 VideoBaseUrl 提取 API origin,用于构建查询地址
parsed, err := url.Parse(cfg.VideoBaseUrl)
if err != nil {
g.Log().Warningf(ctx, "解析VideoBaseUrl失败: %v", err)
return
}
origin := fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
// ----- 视频模型参数默认值(仅当用户未设置时填充)-----
// 通义万相系列(Wan2.6/2.7)文档:T2V 和 I2V 均支持 2-15 秒
// ----- 视频模型参数默认值 -----
if cfg.MinSingleDuration <= 0 {
cfg.MinSingleDuration = 2
g.Log().Infof(ctx, "使用默认单段最小时长: 2 秒")
@@ -115,13 +108,7 @@ func syncModelDurationFromAPI(ctx context.Context, cfg *entity.ModelConfig) {
g.Log().Infof(ctx, "使用默认单段最大时长: 15 秒")
}
// 视频查询地址:用户未设置时从 origin 自动构建
if cfg.VideoQueryUrl == "" {
cfg.VideoQueryUrl = origin + "/api/v1/tasks/{task_id}"
g.Log().Infof(ctx, "自动构建视频查询地址: %s", cfg.VideoQueryUrl)
}
// 视频模型 Temperature 默认值
// Temperature 默认值
if cfg.Temperature <= 0 {
cfg.Temperature = 0.85
g.Log().Infof(ctx, "使用默认 Temperature: 0.85")
@@ -168,69 +155,74 @@ func syncModelDurationFromAPI(ctx context.Context, cfg *entity.ModelConfig) {
return
}
// 确认模型在列表中
for _, m := range chatModelList.Data {
if m.Id == cfg.ChatModelName {
break
}
}
// 根据模型名称模式推断 max_tokens 和 temperature(仅当用户未设置时)
chatModelName := strings.ToLower(cfg.ChatModelName)
if cfg.MaxTokens <= 0 {
chatMaxTokens := 4096 // 默认值
switch {
case strings.Contains(chatModelName, "qwen3"):
chatMaxTokens = 65536
case strings.Contains(chatModelName, "qwen2.5"):
chatMaxTokens = 32768
case strings.Contains(chatModelName, "qwen-max"):
chatMaxTokens = 8192
case strings.Contains(chatModelName, "qwen-plus"):
chatMaxTokens = 16384
case strings.Contains(chatModelName, "gpt-4o"):
chatMaxTokens = 16384
case strings.Contains(chatModelName, "gpt-4"):
chatMaxTokens = 8192
case strings.Contains(chatModelName, "gpt-3.5"):
chatMaxTokens = 4096
case strings.Contains(chatModelName, "deepseek"):
chatMaxTokens = 8192
case strings.Contains(chatModelName, "glm"):
chatMaxTokens = 8192
case strings.Contains(chatModelName, "ernie"):
chatMaxTokens = 8192
}
cfg.MaxTokens = chatMaxTokens
g.Log().Infof(ctx, "根据模型名称推断 max_tokens: %d(用户在表单中可手动修改)", chatMaxTokens)
cfg.MaxTokens = inferChatMaxTokens(chatModelName)
g.Log().Infof(ctx, "根据模型名称推断 max_tokens: %d(用户在表单中可手动修改)", cfg.MaxTokens)
}
if cfg.Temperature <= 0 {
chatTemp := 0.7
switch {
case strings.Contains(chatModelName, "qwen3"):
chatTemp = 0.7
case strings.Contains(chatModelName, "qwen2.5"):
chatTemp = 0.7
case strings.Contains(chatModelName, "qwen-max"):
chatTemp = 0.8
case strings.Contains(chatModelName, "qwen-plus"):
chatTemp = 0.8
case strings.Contains(chatModelName, "gpt-4o"):
chatTemp = 0.8
case strings.Contains(chatModelName, "gpt-4"):
chatTemp = 0.7
case strings.Contains(chatModelName, "gpt-3.5"):
chatTemp = 0.7
case strings.Contains(chatModelName, "deepseek"):
chatTemp = 0.7
case strings.Contains(chatModelName, "glm"):
chatTemp = 0.8
case strings.Contains(chatModelName, "ernie"):
chatTemp = 0.8
}
cfg.Temperature = chatTemp
g.Log().Infof(ctx, "根据模型名称推断 Temperature: %.2f(用户在表单中可手动修改)", chatTemp)
cfg.Temperature = inferChatTemperature(chatModelName)
g.Log().Infof(ctx, "根据模型名称推断 Temperature: %.2f(用户在表单中可手动修改)", cfg.Temperature)
}
}
// inferChatMaxTokens 根据模型名推断合适的 max_tokens
func inferChatMaxTokens(modelName string) int {
switch {
case strings.Contains(modelName, "qwen3"):
return 65536
case strings.Contains(modelName, "qwen2.5"):
return 32768
case strings.Contains(modelName, "qwen-max"):
return 8192
case strings.Contains(modelName, "qwen-plus"):
return 16384
case strings.Contains(modelName, "gpt-4o"):
return 16384
case strings.Contains(modelName, "gpt-4"):
return 8192
case strings.Contains(modelName, "gpt-3.5"):
return 4096
case strings.Contains(modelName, "deepseek"):
return 8192
case strings.Contains(modelName, "doubao") || strings.Contains(modelName, "豆包"):
return 65536
case strings.Contains(modelName, "glm"):
return 8192
case strings.Contains(modelName, "ernie"):
return 8192
default:
return 4096
}
}
// inferChatTemperature 根据模型名推断合适的 temperature
func inferChatTemperature(modelName string) float64 {
switch {
case strings.Contains(modelName, "qwen3"):
return 0.7
case strings.Contains(modelName, "qwen2.5"):
return 0.7
case strings.Contains(modelName, "qwen-max"):
return 0.8
case strings.Contains(modelName, "qwen-plus"):
return 0.8
case strings.Contains(modelName, "gpt-4o"):
return 0.8
case strings.Contains(modelName, "gpt-4"):
return 0.7
case strings.Contains(modelName, "gpt-3.5"):
return 0.7
case strings.Contains(modelName, "deepseek"):
return 0.7
case strings.Contains(modelName, "glm"):
return 0.8
case strings.Contains(modelName, "ernie"):
return 0.8
default:
return 0.7
}
}
+83 -150
View File
@@ -1,7 +1,6 @@
package service
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
@@ -19,6 +18,7 @@ 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"
@@ -549,7 +549,7 @@ func (s *dramaService) buildSegPrompt(ctx context.Context, d *entity.Drama, ep *
- 输出 JSON 必须包含完整的人物定义(characters)和场景定义(scenes)。
- **参考图数量约束:本段中所有演员形象+场景图片+道具图片的合计参考图数量不得超过%d张(首帧已占用1个参考位,剩余%d个参考位供分配)。请合理规划,确保合计数量不超出此限制。**
- **内容密度要求:每秒钟的视频内容必须有足够的视觉信息填充。请确保场景描述(description)细分到位,包含角色的具体动作、表情变化、镜头运动、环境互动等,避免"两个人对话""走过街道"这样过于概括的描述。一段%d秒的视频应该包含流畅的情节推进,不能出现长时间静止或内容稀疏的画面。**
- **内容合规要求:场景描述(description)和台词(lines)将提交给第三方视频生成API,该API有自动化内容审查机制。请避免使用以下可能触发审查的词汇和表达:(1)军事敏感词汇如"军装""军旗""军功章""军人"等,建议替换为"制服""旗帜""荣誉勋章""老兵"等;(2)口号式表达如"若有战召必回"等;(3)激烈动作描写如"握拳""指节发白""目光如炬""紧握""猛然"等,建议使用温和表达。请使用合规语言创作,确保内容能通过自动化审核。**`,
- **内容合规要求:场景描述(description)和台词(lines)将提交给第三方视频生成API,该API有自动化内容审查机制。请避免使用以下可能触发审查的词汇和表达:(1)军事敏感词汇如"军装""军旗""军功章""军人"等,建议替换为"制服""旗帜""荣誉勋章""老兵"等;(2)口号式表达如"若有战召必回"等;(3)激烈动作描写如"握拳""指节发白""目光如炬""紧握""猛然"等,建议使用温和表达。请使用合规语言创作,确保内容能通过自动化审核。**%s`,
PromptService.GetSystemPrompt(ctx),
ep.Index, segIdx+1, totalSegs, segDur,
maxRef, usableRef,
@@ -651,7 +651,9 @@ func (s *dramaService) buildSegUserInput(d *entity.Drama, ep *entity.Episode, se
【当前剧集】
第%d集:%s
剧本:%s
本段时长:%d秒(第%d段/共%d段)`,
本段时长:%d秒(第%d段/共%d段)
【用户反馈】
%s`,
d.Type, d.Title, configPrompt,
ep.Index, ep.Title, scriptText, segDur, segIdx+1, totalSegs,
feedbackText,
@@ -664,7 +666,9 @@ func (s *dramaService) buildSegUserInput(d *entity.Drama, ep *entity.Episode, se
【当前剧集】
第%d集:%s
剧本:%s
本段时长:%d秒(第%d段/共%d段,覆盖整集的第%d-%d秒)`,
本段时长:%d秒(第%d段/共%d段,覆盖整集的第%d-%d秒)
【用户反馈】
%s`,
d.Type, d.Title, configPrompt, sceneText, propText, charText,
ep.Index, ep.Title, scriptText, segDur, segIdx+1, totalSegs, segStartTime+1, segStartTime+segDur,
feedbackText,
@@ -868,7 +872,7 @@ func (s *dramaService) StartVideoPoller(ctx context.Context) {
// pollPendingVideos 扫描所有 generating 任务,轮询或重试视频合成
func (s *dramaService) pollPendingVideos(ctx context.Context) {
modelCfg := ConfigService.Get(ctx)
if modelCfg.VideoApiKey == "" || modelCfg.VideoQueryUrl == "" {
if modelCfg.VideoApiKey == "" || modelCfg.VideoBaseUrl == "" {
return
}
@@ -976,69 +980,47 @@ func (s *dramaService) pollPendingVideos(ctx context.Context) {
}
// pollVideoTaskOnce 单次查询视频任务状态
// pollVideoTaskOnce 单次查询视频任务状态(使用视频适配器)
func (s *dramaService) pollVideoTaskOnce(ctx context.Context, modelCfg *entity.ModelConfig, taskId string) (string, error) {
queryURL := strings.ReplaceAll(modelCfg.VideoQueryUrl, "{task_id}", taskId)
videoAdapter := adapter.NewVideoAdapter(adapter.VideoProvider(modelCfg.VideoProvider), modelCfg.VideoApiKey, modelCfg.VideoBaseUrl, modelCfg.VideoModelName)
req, err := http.NewRequestWithContext(ctx, "GET", queryURL, nil)
res, err := videoAdapter.Query(ctx, taskId)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+modelCfg.VideoApiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", 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"`
Code string `json:"code"`
Message string `json:"message"`
}
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
}
// 提取视频URL:优先 output.video_url,其次 output.results[].video_url/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
}
if res.Status == adapter.VideoTaskFailed {
return "", fmt.Errorf("FAILED: %s", res.ErrorMsg)
}
if !g.IsEmpty(videoURL) {
return videoURL, nil
if res.Status == adapter.VideoTaskRunning || res.Status == adapter.VideoTaskPending {
return "", fmt.Errorf("RUNNING")
}
if result.Output.TaskStatus != "" {
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 "", fmt.Errorf("任务状态: %s", errMsg)
// 未知状态但返回了错误消息
if res.ErrorMsg != "" {
return "", fmt.Errorf("任务状态: %s", res.ErrorMsg)
}
return "", fmt.Errorf("视频URL为空")
}
// ==================== Video Generation ====================
// submitVideoTask 提交视频合成任务(duration 使用指定的 segDur,不再硬编码)
// 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(adapter.VideoProvider(modelCfg.VideoProvider), modelCfg.VideoApiKey, modelCfg.VideoBaseUrl, modelCfg.VideoModelName), nil
}
// 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 == "" {
@@ -1046,7 +1028,6 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep
}
if len(scenes) == 0 {
// 无场景数据时,用段信息作为默认场景描述
g.Log().Warningf(ctx, "第%d集第%d段无场景数据,使用默认场景描述", ep.Index, segIdx+1)
scenes = []model.SegmentScene{
{
@@ -1061,7 +1042,6 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep
sceneDescs = append(sceneDescs, s.Description)
}
// 视频模型输入文本有长度限制,根据模型配置中的 max_tokens 决定截断长度
sceneText := strings.Join(sceneDescs, "")
// 构建参考素材说明
@@ -1085,7 +1065,6 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep
maxInputLen = modelCfg.MaxTokens
}
if len([]rune(prompt)) > maxInputLen {
// 保留前缀,截断过长的场景描述
prefix := fmt.Sprintf("短剧《%s》第%d集第%d段:", d.Title, ep.Index, segIdx+1)
keepLen := maxInputLen - len([]rune(prefix))
if keepLen < 0 {
@@ -1099,98 +1078,62 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep
g.Log().Infof(ctx, "prompt超长已截断至%d字符(原%d字符)", maxInputLen, len([]rune(prompt)))
}
body := map[string]any{
"model": modelCfg.VideoModelName,
"input": func() map[string]any {
m := map[string]any{
"prompt": prompt,
}
if len(refImages) > 0 {
m["images"] = refImages
}
return m
}(),
"parameters": map[string]any{
"size": func() string {
res := d.Resolution
if res == "" {
res = "720P"
}
aspect := d.AspectRatio
if strings.Contains(aspect, "16:9") {
if res == "1080P" {
return "1920*1080"
}
return "1280*720"
}
if strings.Contains(aspect, "1:1") {
if res == "1080P" {
return "1080*1080"
}
return "720*720"
}
// 默认竖屏 9:16
if res == "1080P" {
return "1080*1920"
}
return "720*1280"
}(),
},
}
if segDur > 0 {
body["parameters"].(map[string]any)["duration"] = clampVideoDuration(segDur, modelCfg.VideoModelName)
videoAdapter, err := getVideoAdapter(ctx)
if err != nil {
return "", err
}
for attempt := 0; attempt < 2; attempt++ {
payload, _ := json.Marshal(body)
httpClient := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", modelCfg.VideoBaseUrl, bytes.NewBuffer(payload))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+modelCfg.VideoApiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-DashScope-Async", "enable")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
data, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var result struct {
Output struct {
TaskId string `json:"task_id"`
} `json:"output"`
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("解析视频合成响应失败: %s", string(data))
}
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,去掉后重试一次
if result.Code != "" && strings.Contains(result.Code+" "+result.Message, "duration") &&
strings.Contains(strings.ToLower(result.Message), "not support") && attempt == 0 {
g.Log().Warningf(ctx, "视频模型不支持自定义 duration,去掉后重试")
delete(body["parameters"].(map[string]any), "duration")
continue
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)
}
if result.Code != "" {
return "", fmt.Errorf("视频合成请求失败(code=%s): %s", result.Code, string(data))
if err != nil {
return "", fmt.Errorf("视频合成请求失败: %w", err)
}
taskId := result.Output.TaskId
if taskId == "" {
return "", fmt.Errorf("视频合成任务ID为空")
}
return taskId, nil
}
return "", fmt.Errorf("视频合成请求失败: 重试后仍然失败")
if submitRes == nil || submitRes.TaskID == "" {
return "", fmt.Errorf("视频合成任务ID为空")
}
return submitRes.TaskID, nil
}
// resolveVideoSize 根据分辨率和宽高比计算视频尺寸
func resolveVideoSize(resolution, aspectRatio string) string {
res := resolution
if res == "" {
res = "720P"
}
if strings.Contains(aspectRatio, "16:9") {
if res == "1080P" {
return "1920*1080"
}
return "1280*720"
}
if strings.Contains(aspectRatio, "1:1") {
if res == "1080P" {
return "1080*1080"
}
return "720*720"
}
// 默认竖屏 9:16
if res == "1080P" {
return "1080*1920"
}
return "720*1280"
}
func (s *dramaService) mergeEpisodeVideo(ctx context.Context, epId int64, lastTaskId int64) error {
@@ -1311,7 +1254,7 @@ func (s *dramaService) mergeEpisodeVideo(ctx context.Context, epId int64, lastTa
// 用于串行模式中让下一段能提取上一段的尾帧作为首帧。
func (s *dramaService) waitForSegmentVideo(ctx context.Context, d *entity.Drama, ep *entity.Episode, taskId int64) error {
modelCfg := ConfigService.Get(ctx)
if modelCfg.VideoApiKey == "" || modelCfg.VideoQueryUrl == "" {
if modelCfg.VideoApiKey == "" || modelCfg.VideoBaseUrl == "" {
return fmt.Errorf("视频模型未配置或查询地址为空")
}
@@ -1635,13 +1578,3 @@ func formatDramaConfig(contentType, configStr string) string {
}
return ""
}
func clampVideoDuration(d int, modelName string) int {
if d < 1 {
return 1
}
if d > 60 {
return 60
}
return d
}