1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
} {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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:"更新时间"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user