diff --git a/go.mod b/go.mod index 33c24c1..3d46eed 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index a3f661b..ee8efec 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/short_drama.db b/short_drama.db index c5211c5..6547712 100644 Binary files a/short_drama.db and b/short_drama.db differ diff --git a/shortdrama/agent/chat_model.go b/shortdrama/agent/chat_model.go index fc402ac..3699ff4 100644 --- a/shortdrama/agent/chat_model.go +++ b/shortdrama/agent/chat_model.go @@ -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) } diff --git a/shortdrama/controller/config_controller.go b/shortdrama/controller/config_controller.go index 872d6ca..a997141 100644 --- a/shortdrama/controller/config_controller.go +++ b/shortdrama/controller/config_controller.go @@ -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, diff --git a/shortdrama/dao/model_config_dao.go b/shortdrama/dao/model_config_dao.go index 895fec4..5e182bf 100644 --- a/shortdrama/dao/model_config_dao.go +++ b/shortdrama/dao/model_config_dao.go @@ -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", } { diff --git a/shortdrama/model/adapter/chat.go b/shortdrama/model/adapter/chat.go new file mode 100644 index 0000000..dba5aa0 --- /dev/null +++ b/shortdrama/model/adapter/chat.go @@ -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" // DeepSeek(OpenAI兼容协议) + ChatProviderDoubao ChatProvider = "doubao" // 豆包(OpenAI兼容协议) + ChatProviderGLM ChatProvider = "glm" // 智谱GLM(OpenAI兼容协议) + ChatProviderErnie ChatProvider = "ernie" // 文心ERNIE(OpenAI兼容协议) + ChatProviderOpenAI ChatProvider = "openai" // OpenAI(GPT/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) + } +} diff --git a/shortdrama/model/adapter/chat_openai.go b/shortdrama/model/adapter/chat_openai.go new file mode 100644 index 0000000..f61b323 --- /dev/null +++ b/shortdrama/model/adapter/chat_openai.go @@ -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 +} diff --git a/shortdrama/model/adapter/chat_qwen.go b/shortdrama/model/adapter/chat_qwen.go new file mode 100644 index 0000000..2b4b730 --- /dev/null +++ b/shortdrama/model/adapter/chat_qwen.go @@ -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) +} diff --git a/shortdrama/model/adapter/video.go b/shortdrama/model/adapter/video.go new file mode 100644 index 0000000..351cc51 --- /dev/null +++ b/shortdrama/model/adapter/video.go @@ -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) + } +} diff --git a/shortdrama/model/adapter/video_dashscope.go b/shortdrama/model/adapter/video_dashscope.go new file mode 100644 index 0000000..4c2ba6e --- /dev/null +++ b/shortdrama/model/adapter/video_dashscope.go @@ -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 + } +} diff --git a/shortdrama/model/adapter/video_volcano.go b/shortdrama/model/adapter/video_volcano.go new file mode 100644 index 0000000..c9815d0 --- /dev/null +++ b/shortdrama/model/adapter/video_volcano.go @@ -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 + } +} diff --git a/shortdrama/model/dto/config_dto.go b/shortdrama/model/dto/config_dto.go index 8bf983f..d223ce1 100644 --- a/shortdrama/model/dto/config_dto.go +++ b/shortdrama/model/dto/config_dto.go @@ -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"` } diff --git a/shortdrama/model/entity/model_config.go b/shortdrama/model/entity/model_config.go index c49d990..6869832 100644 --- a/shortdrama/model/entity/model_config.go +++ b/shortdrama/model/entity/model_config.go @@ -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:"更新时间"` diff --git a/shortdrama/service/config_service.go b/shortdrama/service/config_service.go index b05f8c7..4d65e85 100644 --- a/shortdrama/service/config_service.go +++ b/shortdrama/service/config_service.go @@ -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 } } diff --git a/shortdrama/service/drama_service.go b/shortdrama/service/drama_service.go index 90813b5..6f5310b 100644 --- a/shortdrama/service/drama_service.go +++ b/shortdrama/service/drama_service.go @@ -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 -}