1
This commit is contained in:
+6
-1
@@ -8,4 +8,9 @@ server:
|
||||
name: video
|
||||
workerId: 1
|
||||
clientMaxBodySize: 209715200 # 200MB 上传文件限制
|
||||
requestTimeout: 180 # HTTP请求超时(秒),默认60秒,改为3分钟以支持AI生成脚本
|
||||
requestTimeout: 300 # HTTP请求超时(秒),默认60秒,改为3分钟以支持AI生成脚本
|
||||
|
||||
# AI模型调用配置
|
||||
chat:
|
||||
timeout: 300 # 对话模型API请求超时时间(秒)
|
||||
max_retries: 3 # 请求失败最大重试次数
|
||||
|
||||
@@ -67,4 +67,24 @@
|
||||
|
||||
## 剧本生成
|
||||
|
||||
你是一位专业的短剧编剧。请根据剧情描述和可用演员、场景、道具,创作一份详细的单集剧本。创作时严格以剧情描述为核心,不要自行编造与剧情描述无关的情节。
|
||||
你是一位专业的短剧编剧。请根据剧情描述和可用演员、场景、道具,创作一份详细的单集剧本。
|
||||
|
||||
### 核心原则:剧情描述是内容下限,不是上限
|
||||
|
||||
- **保留所有细节**:剧情描述中出现的每一个具体动作、表情变化、对话互动、环境细节都必须完整保留在对应的镜头中,不能省略、概括或泛化(例如"百姓指指点点"是泛化,原文写了老汉瞪眼、妇人交头接耳、小孩摸轮胎被拽回,就必须把这些具体细节写进镜头)。
|
||||
- **丰富而非删减**:在保留全部原有细节的基础上,可以增加合理的微观动作、反应表情、环境互动来让每个镜头更丰满。不要删除任何已有内容来"精简"。
|
||||
- **禁止概括**:如果你发现自己把一个具体的动作写成了概括性的描述,说明你在删减。请回到剧情描述中把具体细节还原出来。
|
||||
|
||||
### 旁白与主台词区分
|
||||
|
||||
每个镜头必须严格区分以下两种内容:
|
||||
- **旁白(narration)**:画外音解说,以第三人称描述场景背景、角色状态、时间地点等。旁白不是角色说的话,而是解说性的叙述文字。
|
||||
- **主台词(dialogue)**:角色在画面中亲口说出的对白和台词。如果有多个角色对话,请标注角色名。
|
||||
- 短剧需要同时有旁白解说和角色对白来推动剧情,两者缺一不可。
|
||||
|
||||
### 节奏要求
|
||||
短剧节奏必须紧凑明快,遵循以下原则:
|
||||
1. **快速推进**:每1-3秒内必须有新的情节信息、动作、对白或事件转折,不能让观众感到内容稀疏。
|
||||
2. **高事件密度**:将剧情描述中的情节密集地分配到时间线上,确保每一秒都有实质内容,每个镜头必须有明确的情节日地。
|
||||
3. **避免松散**:镜头切换要频繁,单个镜头不宜过长。如果一个镜头超过8秒还没有新信息出现,说明太松散,请拆分或加速节奏。
|
||||
4. **拒绝空镜头**:不要为了凑时长而加入无情节推进作用的过渡性镜头,每一帧都要服务于叙事。
|
||||
Binary file not shown.
@@ -21,6 +21,7 @@ type ModelConfig struct {
|
||||
MaxTokens int // 最大Token数
|
||||
Temperature float32 // 温度参数
|
||||
Timeout time.Duration // HTTP请求超时(0表示默认)
|
||||
MaxRetries int // 最大重试次数(0表示默认3次)
|
||||
}
|
||||
|
||||
// CallChatModel 调用大模型聊天接口(OpenAI 兼容格式)
|
||||
@@ -40,7 +41,7 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 180 * time.Second
|
||||
timeout = 300 * time.Second
|
||||
}
|
||||
|
||||
body, err := buildReqBody(cfg.ModelName, req)
|
||||
@@ -49,10 +50,14 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch
|
||||
}
|
||||
|
||||
url := trimSlashes(cfg.BaseURL) + "/v1/chat/completions"
|
||||
g.Log().Infof(ctx, "ChatAPI 开始调用 model=%s timeout=%v max_retries=3 body_size=%d", cfg.ModelName, timeout, len(body))
|
||||
|
||||
var lastErr error
|
||||
maxRetries := 3
|
||||
maxRetries := cfg.MaxRetries
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = 3
|
||||
}
|
||||
g.Log().Infof(ctx, "ChatAPI 开始调用 model=%s timeout=%v max_retries=%d body_size=%d", cfg.ModelName, timeout, maxRetries, len(body))
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
wait := time.Duration(1<<(attempt-1)) * time.Second
|
||||
@@ -68,6 +73,7 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch
|
||||
if doErr == nil {
|
||||
g.Log().Infof(ctx, "ChatAPI 调用成功 url=%s tool_calls=%d content_len=%d",
|
||||
url, len(result.ToolCalls), len(result.Content))
|
||||
g.Log().Printf(ctx, "ChatAPI 返回内容:\n%s", result.Content)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -117,7 +123,7 @@ func doChatRequest(ctx context.Context, url, apiKey string, body []byte, timeout
|
||||
g.Log().Infof(ctx, "ChatAPI 响应完成 status=%d body_len=%d elapsed=%v",
|
||||
resp.StatusCode, len(respBody), elapsed)
|
||||
|
||||
return parseRespBody(respBody)
|
||||
return parseRespBody(ctx, respBody)
|
||||
}
|
||||
|
||||
// ==================== 内部实现 ====================
|
||||
@@ -160,8 +166,9 @@ type apiRespBody struct {
|
||||
}
|
||||
|
||||
type apiChoice struct {
|
||||
Index int `json:"index"`
|
||||
Message apiRespMsg `json:"message"`
|
||||
Index int `json:"index"`
|
||||
Message apiRespMsg `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// apiRespMsg 响应消息体(arguments 使用 json.RawMessage 兼容对象和字符串)
|
||||
@@ -230,7 +237,7 @@ func toAPIMessages(msgs []*ChatMessage) []apiMessage {
|
||||
om.ToolCalls = make([]apiToolCall, 0, len(m.ToolCalls))
|
||||
for _, tc := range m.ToolCalls {
|
||||
args := tc.Arguments
|
||||
if args == "" {
|
||||
if args == "" || !json.Valid([]byte(args)) {
|
||||
args = "{}"
|
||||
}
|
||||
om.ToolCalls = append(om.ToolCalls, apiToolCall{
|
||||
@@ -248,7 +255,7 @@ func toAPIMessages(msgs []*ChatMessage) []apiMessage {
|
||||
return out
|
||||
}
|
||||
|
||||
func parseRespBody(data []byte) (*ChatResponse, error) {
|
||||
func parseRespBody(ctx context.Context, data []byte) (*ChatResponse, error) {
|
||||
var resp apiRespBody
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %s", string(data))
|
||||
@@ -262,6 +269,12 @@ func parseRespBody(data []byte) (*ChatResponse, error) {
|
||||
|
||||
msg := resp.Choices[0].Message
|
||||
cr := &ChatResponse{Content: msg.Content}
|
||||
|
||||
// 检测 finish_reason 是否为 length(被 max_tokens 截断)
|
||||
if resp.Choices[0].FinishReason == "length" {
|
||||
g.Log().Warningf(ctx, "ChatAPI 响应被截断(finish_reason=length), 当前content_len=%d, 请考虑增大max_tokens", len(msg.Content))
|
||||
}
|
||||
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
cr.ToolCalls = make([]*ToolCall, 0, len(msg.ToolCalls))
|
||||
for _, tc := range msg.ToolCalls {
|
||||
|
||||
@@ -9,13 +9,15 @@ import (
|
||||
// Shot 单集剧本中的单个镜头
|
||||
type Shot struct {
|
||||
Index int `json:"index"`
|
||||
StartTime string `json:"startTime"` // "MM:SS"
|
||||
EndTime string `json:"endTime"` // "MM:SS"
|
||||
Event string `json:"event"` // 事件描述
|
||||
CameraMovement string `json:"cameraMovement"` // 运镜描述
|
||||
Characters []string `json:"characters"` // 出演人物(演员名列表)
|
||||
Scene string `json:"scene"` // 场景(场景名)
|
||||
Props []string `json:"props"` // 道具(道具名列表)
|
||||
StartTime string `json:"startTime"` // "MM:SS"
|
||||
EndTime string `json:"endTime"` // "MM:SS"
|
||||
Event string `json:"event"` // 事件描述/动作描写
|
||||
Narration string `json:"narration,omitempty"` // 旁白(画外音解说,不是角色说的话)
|
||||
Dialogue string `json:"dialogue,omitempty"` // 主台词(角色亲口说的对白)
|
||||
CameraMovement string `json:"cameraMovement"` // 运镜描述
|
||||
Characters []string `json:"characters"` // 出演人物(演员名列表)
|
||||
Scene string `json:"scene"` // 场景(场景名)
|
||||
Props []string `json:"props"` // 道具(道具名列表)
|
||||
}
|
||||
|
||||
// ShotsToText 将镜头数组转回纯文本格式,供视频生成流程(ReAct Agent)使用
|
||||
@@ -26,6 +28,12 @@ func ShotsToText(shots []Shot) string {
|
||||
}
|
||||
for _, s := range shots {
|
||||
fmt.Fprintf(&b, "【镜头%d】(%s-%s)\n", s.Index, s.StartTime, s.EndTime)
|
||||
if s.Narration != "" {
|
||||
fmt.Fprintf(&b, "旁白:%s\n", s.Narration)
|
||||
}
|
||||
if s.Dialogue != "" {
|
||||
fmt.Fprintf(&b, "台词:%s\n", s.Dialogue)
|
||||
}
|
||||
if s.Event != "" {
|
||||
fmt.Fprintf(&b, "事件:%s\n", s.Event)
|
||||
}
|
||||
|
||||
@@ -389,66 +389,72 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama,
|
||||
}
|
||||
totalSegs := len(segDurs)
|
||||
|
||||
// 调用 Agent
|
||||
result, err := s.generateSegment(ctx, d, ep, segIdx, segDur, segStartTime, totalSegs, modelCfg, feedback, genCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
segOutput := model.ParseAgentOutput(result, segIdx)
|
||||
if len(segOutput.Scenes) == 0 {
|
||||
g.Log().Warningf(ctx, "第%d集第%d段 Agent 未生成场景数据,请检查输入", ep.Index, segIdx+1)
|
||||
}
|
||||
|
||||
// 从 Agent 原始输出中提取图片数据,回填到 segOutput
|
||||
images := model.ExtractAgentImages(result)
|
||||
for i := range segOutput.Characters {
|
||||
if b64, ok := images.CharacterImages[segOutput.Characters[i].Name]; ok {
|
||||
segOutput.Characters[i].ImageBase64 = b64
|
||||
// JSON 镜头脚本跳过 Agent,直接构建场景描述提交视频模型
|
||||
var segOutput *model.SegmentOutput
|
||||
if domain.IsShotsJSON(ep.Script) && feedback == "" {
|
||||
segOutput = buildSegOutputFromShots(ep.Script, segIdx, segStartTime, segDur)
|
||||
g.Log().Infof(ctx, "第%d集第%d段 JSON镜头直接提交: %d个镜头", ep.Index, segIdx+1, len(segOutput.Scenes))
|
||||
} else {
|
||||
result, err := s.generateSegment(ctx, d, ep, segIdx, segDur, segStartTime, totalSegs, modelCfg, feedback, genCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i := range segOutput.Scenes {
|
||||
if i < len(images.SceneImages) {
|
||||
segOutput.Scenes[i].ImageBase64 = images.SceneImages[i].Base64
|
||||
segOutput = model.ParseAgentOutput(result, segIdx)
|
||||
if len(segOutput.Scenes) == 0 {
|
||||
g.Log().Warningf(ctx, "第%d集第%d段 Agent 未生成场景数据,请检查输入", ep.Index, segIdx+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 重新加载演员列表(包含新插入的形象路径)
|
||||
characters, _, _ = dao.Character.ListPageByDrama(ctx, d.Id, 1, -1)
|
||||
// 从 Agent 原始输出中提取图片数据
|
||||
images := model.ExtractAgentImages(result)
|
||||
for i := range segOutput.Characters {
|
||||
if b64, ok := images.CharacterImages[segOutput.Characters[i].Name]; ok {
|
||||
segOutput.Characters[i].ImageBase64 = b64
|
||||
}
|
||||
}
|
||||
for i := range segOutput.Scenes {
|
||||
if i < len(images.SceneImages) {
|
||||
segOutput.Scenes[i].ImageBase64 = images.SceneImages[i].Base64
|
||||
}
|
||||
}
|
||||
|
||||
// 从数据库已有形象回填 base64(优先于 Agent 生成的临时图片)
|
||||
if len(characters) > 0 {
|
||||
charMap := make(map[string]string)
|
||||
for _, c := range characters {
|
||||
if c.PortraitPath != "" {
|
||||
if b64, err := imageFileToBase64(c.PortraitPath); err == nil {
|
||||
charMap[c.Name] = b64
|
||||
// 重新加载演员列表
|
||||
characters, _, _ = dao.Character.ListPageByDrama(ctx, d.Id, 1, -1)
|
||||
|
||||
// 从数据库已有形象回填 base64
|
||||
if len(characters) > 0 {
|
||||
charMap := make(map[string]string)
|
||||
for _, c := range characters {
|
||||
if c.PortraitPath != "" {
|
||||
if b64, err := imageFileToBase64(c.PortraitPath); err == nil {
|
||||
charMap[c.Name] = b64
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range segOutput.Characters {
|
||||
name := segOutput.Characters[i].Name
|
||||
if img, ok := charMap[name]; ok {
|
||||
segOutput.Characters[i].ImageBase64 = img
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range segOutput.Characters {
|
||||
name := segOutput.Characters[i].Name
|
||||
if img, ok := charMap[name]; ok {
|
||||
segOutput.Characters[i].ImageBase64 = img
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存场景图片到 workspace
|
||||
dramaTitle := d.Title
|
||||
for i := range segOutput.Scenes {
|
||||
scene := &segOutput.Scenes[i]
|
||||
if scene.ImageBase64 != "" {
|
||||
if path, err := s.saveBase64Image(ctx, dramaTitle, scene.ImageBase64, "场景",
|
||||
fmt.Sprintf("seg_%d_scene_%d.png", segIdx, i)); err == nil {
|
||||
scene.ImagePath = path
|
||||
scene.ImageBase64 = "" // 清除 base64
|
||||
} else {
|
||||
g.Log().Warningf(ctx, "保存场景图片失败: %v", err)
|
||||
// 保存场景图片到 workspace
|
||||
dramaTitle := d.Title
|
||||
for i := range segOutput.Scenes {
|
||||
scene := &segOutput.Scenes[i]
|
||||
if scene.ImageBase64 != "" {
|
||||
if path, err := s.saveBase64Image(ctx, dramaTitle, scene.ImageBase64, "场景",
|
||||
fmt.Sprintf("seg_%d_scene_%d.png", segIdx, i)); err == nil {
|
||||
scene.ImagePath = path
|
||||
scene.ImageBase64 = ""
|
||||
} else {
|
||||
g.Log().Warningf(ctx, "保存场景图片失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 读取 task 中已有的 NumSegments
|
||||
// 读取 task 中已有的 NumSegments
|
||||
task, _ := dao.GenerationTask.GetOne(ctx, taskId)
|
||||
numSegments := 1
|
||||
@@ -548,6 +554,65 @@ func (s *dramaService) generateOneSegment(ctx context.Context, d *entity.Drama,
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildSegOutputFromShots 从 JSON 镜头数组直接构建 SegmentOutput(跳过 Agent)
|
||||
// 将本段所有镜头合并为一段连贯的场景描述,去掉"旁白/台词/事件"标记,适合视频模型理解
|
||||
func buildSegOutputFromShots(script string, segIdx, segStartTime, segDur int) *model.SegmentOutput {
|
||||
var allShots []domain.Shot
|
||||
if err := json.Unmarshal([]byte(script), &allShots); err != nil || len(allShots) == 0 {
|
||||
return &model.SegmentOutput{Index: segIdx, TextOutput: script}
|
||||
}
|
||||
|
||||
segEndTime := segStartTime + segDur
|
||||
var segShots []domain.Shot
|
||||
charSet := make(map[string]bool)
|
||||
for _, sh := range allShots {
|
||||
shStart := parseMMSSToSeconds(sh.StartTime)
|
||||
shEnd := parseMMSSToSeconds(sh.EndTime)
|
||||
if shEnd <= segStartTime || shStart >= segEndTime {
|
||||
continue
|
||||
}
|
||||
segShots = append(segShots, sh)
|
||||
for _, c := range sh.Characters {
|
||||
if c != "" {
|
||||
charSet[c] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var chars []model.SegmentCharacter
|
||||
for name := range charSet {
|
||||
chars = append(chars, model.SegmentCharacter{Name: name})
|
||||
}
|
||||
|
||||
// 将所有镜头合并为一段连贯描述(去掉旁白/台词/事件标记,自然语言拼接)
|
||||
descParts := make([]string, 0, len(segShots)*2)
|
||||
for _, sh := range segShots {
|
||||
// 旁白直接作为叙述
|
||||
if sh.Narration != "" {
|
||||
descParts = append(descParts, sh.Narration)
|
||||
}
|
||||
// 事件描述
|
||||
if sh.Event != "" {
|
||||
descParts = append(descParts, sh.Event)
|
||||
}
|
||||
// 对话以自然方式嵌入
|
||||
if sh.Dialogue != "" {
|
||||
descParts = append(descParts, sh.Dialogue)
|
||||
}
|
||||
}
|
||||
fullDesc := strings.Join(descParts, ",")
|
||||
|
||||
return &model.SegmentOutput{
|
||||
Index: segIdx,
|
||||
Scenes: []model.SegmentScene{{
|
||||
Index: 0,
|
||||
Description: fullDesc,
|
||||
Duration: segDur,
|
||||
}},
|
||||
Characters: chars,
|
||||
}
|
||||
}
|
||||
|
||||
// generateSegment 调用 Agent 生成一段内容
|
||||
func (s *dramaService) generateSegment(ctx context.Context, d *entity.Drama, ep *entity.Episode,
|
||||
segIdx, segDur, segStartTime, totalSegs int, modelCfg *entity.ModelConfig, feedback string,
|
||||
@@ -641,7 +706,7 @@ func (s *dramaService) buildSegPrompt(ctx context.Context, d *entity.Drama, ep *
|
||||
- 每个演员应保持形象一致,参考形象文件路径中的图片。
|
||||
- 输出 JSON 必须包含完整的人物定义(characters)和场景定义(scenes)。
|
||||
- **参考图数量约束:本段中所有演员形象+场景图片+道具图片的合计参考图数量不得超过%d张(首帧已占用1个参考位,剩余%d个参考位供分配)。请合理规划,确保合计数量不超出此限制。**
|
||||
- **内容密度要求:每秒钟的视频内容必须有足够的视觉信息填充。请确保场景描述(description)细分到位,包含角色的具体动作、表情变化、镜头运动、环境互动等,避免"两个人对话""走过街道"这样过于概括的描述。一段%d秒的视频应该包含流畅的情节推进,不能出现长时间静止或内容稀疏的画面。**
|
||||
- **节奏与密度要求:短剧节奏必须紧凑明快,每3-8秒内必须有新的情节推进、动作、对白或事件转折,避免拖沓松散。请确保场景描述(description)细分到位,包含角色的具体动作、表情变化、镜头运动、环境互动等,避免"两个人对话""走过街道"这样过于概括的描述。一段%d秒的视频应该包含流畅的情节推进,不能出现长时间静止或内容稀疏的画面。镜头切换要频繁,每个镜头必须有明确的情节日地,删除所有无信息量的过渡性空镜头。**
|
||||
- **内容合规要求:场景描述(description)和台词(lines)将提交给第三方视频生成API,该API有自动化内容审查机制。请避免使用以下可能触发审查的词汇和表达:(1)军事敏感词汇如"军装""军旗""军功章""军人"等,建议替换为"制服""旗帜""荣誉勋章""老兵"等;(2)口号式表达如"若有战召必回"等;(3)激烈动作描写如"握拳""指节发白""目光如炬""紧握""猛然"等,建议使用温和表达。请使用合规语言创作,确保内容能通过自动化审核。**%s`,
|
||||
PromptService.GetSystemPrompt(ctx),
|
||||
ep.Index, segIdx+1, totalSegs, segDur,
|
||||
@@ -1320,7 +1385,6 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep
|
||||
|
||||
// 构建 prompt:将场景描述中的实体名称替换为 character1/character2/... 直接引用参考素材
|
||||
refURLs := make([]string, len(namedURLs))
|
||||
refExplanation := ""
|
||||
if len(namedURLs) > 0 {
|
||||
type nameLabel struct {
|
||||
name string
|
||||
@@ -1344,22 +1408,18 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep
|
||||
for i, nu := range namedURLs {
|
||||
refParts = append(refParts, fmt.Sprintf("character%d=%s", i+1, nu.name))
|
||||
}
|
||||
refExplanation = fmt.Sprintf("。角色引用说明:%s", strings.Join(refParts, "、"))
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf("短剧《%s》第%d集第%d段:%s%s", d.Title, ep.Index, segIdx+1, sceneText, refExplanation)
|
||||
prompt := sceneText
|
||||
if promptMaxChars > 0 && len([]rune(prompt)) > promptMaxChars {
|
||||
prefix := fmt.Sprintf("短剧《%s》第%d集第%d段:", d.Title, ep.Index, segIdx+1)
|
||||
refLen := len([]rune(refExplanation))
|
||||
keepSceneLen := promptMaxChars - refLen - len([]rune(prefix))
|
||||
if keepSceneLen < 50 {
|
||||
keepSceneLen = 50
|
||||
keepLen := promptMaxChars
|
||||
if keepLen < 50 {
|
||||
keepLen = 50
|
||||
}
|
||||
sceneRunes := []rune(sceneText)
|
||||
if keepSceneLen < len(sceneRunes) {
|
||||
sceneText = "..." + string(sceneRunes[len(sceneRunes)-keepSceneLen+3:])
|
||||
promptRunes := []rune(prompt)
|
||||
if keepLen < len(promptRunes) {
|
||||
prompt = "..." + string(promptRunes[len(promptRunes)-keepLen+3:])
|
||||
}
|
||||
prompt = prefix + sceneText + refExplanation
|
||||
g.Log().Infof(ctx, "prompt超长已截断至%d字符(原%d字符)", promptMaxChars, len([]rune(prompt)))
|
||||
}
|
||||
|
||||
@@ -1371,17 +1431,39 @@ func (s *dramaService) submitVideoTask(ctx context.Context, d *entity.Drama, ep
|
||||
}
|
||||
}
|
||||
|
||||
// 从 video_schema 判断当前模型是否支持 duration 参数
|
||||
effectiveDur := segDur
|
||||
if modelCfg.VideoSchema != "" {
|
||||
var vs map[string]any
|
||||
if err := json.Unmarshal([]byte(modelCfg.VideoSchema), &vs); err == nil {
|
||||
if durDef := nested(vs, "parameters", "duration"); durDef != nil {
|
||||
if durMap, ok := durDef.(map[string]any); ok {
|
||||
if sm, ok := durMap["supported_models"]; ok {
|
||||
if models, ok := sm.([]any); ok {
|
||||
found := false
|
||||
for _, m := range models {
|
||||
if ms, ok := m.(string); ok && ms == modelCfg.VideoModelName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
effectiveDur = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// schema 未定义 duration 参数 → 不支持自定义时长
|
||||
effectiveDur = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
taskId, requestJSON, err := createVideoTask(ctx, modelCfg.VideoApiKey, modelCfg.VideoBaseUrl, modelCfg.VideoModelName,
|
||||
prompt, negativePrompt, refURLs, segDur, d.Resolution, d.AspectRatio, modelCfg.VideoSchema)
|
||||
prompt, negativePrompt, refURLs, effectiveDur, d.Resolution, d.AspectRatio, modelCfg.VideoSchema)
|
||||
if err != nil {
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if strings.Contains(errStr, "duration") && (strings.Contains(errStr, "not support") || strings.Contains(errStr, "not supported")) {
|
||||
taskId, requestJSON, err = createVideoTask(ctx, modelCfg.VideoApiKey, modelCfg.VideoBaseUrl, modelCfg.VideoModelName,
|
||||
prompt, negativePrompt, refURLs, 0, d.Resolution, d.AspectRatio, modelCfg.VideoSchema)
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("视频合成请求失败: %w", err)
|
||||
}
|
||||
return "", "", fmt.Errorf("视频合成请求失败: %w", err)
|
||||
}
|
||||
|
||||
if taskId == "" {
|
||||
@@ -1403,37 +1485,11 @@ func createVideoTask(ctx context.Context, apiKey, baseURL, modelName, prompt, ne
|
||||
"prompt": prompt,
|
||||
},
|
||||
}
|
||||
|
||||
body["input"].(map[string]any)["reference_urls"] = refURLs
|
||||
// input.negative_prompt — 反向提示词(从 negative_prompt.md 读取,不为空时才发送)
|
||||
if negativePrompt != "" {
|
||||
body["input"].(map[string]any)["negative_prompt"] = negativePrompt
|
||||
}
|
||||
|
||||
// input.reference_urls — 纯 URL 字符串数组,顺序对应 character1/character2/...
|
||||
// 解析 refURLs:http/https/data: 开头的保持原样,文件路径转为 base64 data URL
|
||||
for i, u := range refURLs {
|
||||
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "data:") {
|
||||
continue
|
||||
}
|
||||
if b64, err := imageFileToBase64(u); err == nil {
|
||||
refURLs[i] = b64
|
||||
} else {
|
||||
g.Log().Warningf(ctx, "reference_urls 元素无法解析为文件或URL,跳过: %s", u)
|
||||
}
|
||||
}
|
||||
if len(refURLs) > 0 {
|
||||
body["input"].(map[string]any)["reference_urls"] = refURLs
|
||||
} else {
|
||||
// 无参考图时使用默认首帧
|
||||
if imgData, err := os.ReadFile(DefaultFirstFramePath); err == nil {
|
||||
b64 := base64.StdEncoding.EncodeToString(imgData)
|
||||
body["input"].(map[string]any)["reference_urls"] = []string{"data:image/png;base64," + b64}
|
||||
g.Log().Infof(ctx, "使用默认首帧图作为 reference_urls")
|
||||
} else {
|
||||
g.Log().Warningf(ctx, "读取默认首帧图失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 从 video_schema 读取模型特定参数(结构按 API 请求格式: input / parameters)
|
||||
params := map[string]any{}
|
||||
if videoSchema != "" {
|
||||
@@ -1485,6 +1541,8 @@ func createVideoTask(ctx context.Context, apiKey, baseURL, modelName, prompt, ne
|
||||
body["parameters"] = params
|
||||
|
||||
payload, _ := json.Marshal(body)
|
||||
promptStr, _ := body["input"].(map[string]any)["prompt"].(string)
|
||||
g.Log().Infof(ctx, "视频API请求 body_size=%d prompt_len=%d prompt:\n%s", len(payload), len([]rune(promptStr)), promptStr)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", baseURL, strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("创建请求失败: %w", err)
|
||||
|
||||
@@ -206,7 +206,8 @@ func (s *dramaService) GenerateScript(ctx context.Context, dramaId int64, episod
|
||||
BaseURL: modelCfg.ChatBaseUrl,
|
||||
MaxTokens: modelCfg.MaxTokens,
|
||||
Temperature: float32(modelCfg.Temperature),
|
||||
Timeout: 3 * time.Minute,
|
||||
Timeout: time.Duration(g.Cfg().MustGet(ctx, "chat.timeout", 180).Int()) * time.Second,
|
||||
MaxRetries: g.Cfg().MustGet(ctx, "chat.max_retries", 3).Int(),
|
||||
}
|
||||
|
||||
messages := []*agent.ChatMessage{
|
||||
@@ -233,10 +234,12 @@ func (s *dramaService) GenerateScript(ctx context.Context, dramaId int64, episod
|
||||
if parseErr := json.Unmarshal([]byte(raw), &shots); parseErr == nil && len(shots) > 0 {
|
||||
script = raw
|
||||
g.Log().Infof(ctx, "JSON镜头脚本生成成功: 短剧=%s, 剧集=%s, 镜头数=%d", d.Title, episodeTitle, len(shots))
|
||||
|
||||
} else {
|
||||
// 非JSON格式,作为纯文本返回
|
||||
script = raw
|
||||
g.Log().Infof(ctx, "文本脚本生成成功(非JSON): 短剧=%s, 剧集=%s, 长度=%d字符", d.Title, episodeTitle, len([]rune(raw)))
|
||||
g.Log().Printf(ctx, "模型返回的文本:\n%s", raw)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -287,7 +290,7 @@ func (s *dramaService) buildScriptGenUserInput(d *entity.Drama, episodeTitle, de
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("【要求】\n请根据以上剧情描述,为当前剧集创作一份详细的剧本。剧情描述是核心创作依据,请严格围绕其内容展开。\n\n输出格式:JSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS)\n- endTime: 结束时间(格式MM:SS)\n- event: 事件描述(该镜头中发生的情节概括)\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具名称数组,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n要求:\n1. 剧本必须紧扣剧情描述展开\n2. 所有镜头时长之和应等于 %d 秒(每个镜头%d-%d秒不等)\n3. 优先使用提供的演员、场景和道具,如需新增请合理创作\n4. 每个镜头的characters、scene、props字段必须从提供的可用列表中选取名称,确保与参考素材一致\n5. 对话自然流畅,情节有起伏\n6. 镜头数量建议6-15个\n", d.EpisodeDuration, d.MinShotDuration, d.MaxShotDuration))
|
||||
b.WriteString(fmt.Sprintf("【要求】\n请根据以上剧情描述,为当前剧集创作一份详细的剧本。剧情描述是核心创作依据,请严格围绕其内容展开。\n\n输出格式:JSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS)\n- endTime: 结束时间(格式MM:SS)\n- narration: 旁白(画外音解说),描述场景背景、角色状态、时间地点等,第三人称叙述。不是角色说的话。\n- dialogue: 主台词(角色亲口说的对白),如角色A说「你好」,角色B说「再见」。多人对话可用A:...B:...格式。\n- event: 事件描述/动作描写(该镜头中发生的情节概括和角色动作)\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具名称数组,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n要求:\n1. 剧本必须紧扣剧情描述展开\n2. 所有镜头时长之和应等于 %d 秒(每个镜头%d-%d秒不等)\n3. 优先使用提供的演员、场景和道具,如需新增请合理创作\n4. 每个镜头的characters、scene、props字段必须从提供的可用列表中选取名称,确保与参考素材一致\n5. **严格区分旁白和主台词:narration是画外音(描述场景/角色),dialogue是角色在画面中说出口的对话。不要混淆两者。短剧需要同时有旁白解说和角色对白来推动剧情。**\n6. **节奏紧凑:剧情推进要快速有力,避免拖沓。每%d-%d秒内必须有新的情节信息、动作、对白或事件转折,不能让观众感到内容稀疏。**\n7. **事件密度:将剧情描述中的情节密集地分配到时间线上,确保每一秒都有实质内容。每个镜头必须有明确的情节目的,删除所有无信息量的过渡性空镜头。**\n8. **避免松散:镜头切换要频繁,单个镜头不宜过长。如果一个镜头超过%d秒还没有新信息出现,说明太松散,请拆分或加速节奏。**\n9. **禁止删减和泛化:剧情描述中的每一个具体细节(人物的动作、表情反应、对话、环境互动等)都必须忠实地保留在对应镜头的event、narration或dialogue字段中,不能省略、概括或改写为泛化描述。例如「百姓指指点点」是泛化,必须写出具体谁做了什么表情/动作。「衙役尝了说好」是泛化,必须写出闻香→咽口水→试探咬→瞳孔放大→猛咬→竖拇指→满嘴油光这整个递进过程。如果你发现在把原文细节丢掉,请停下来把细节还原回去。**\n", d.EpisodeDuration, d.MinShotDuration, d.MaxShotDuration, d.MinShotDuration, d.MaxShotDuration, d.MaxShotDuration))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -466,11 +469,14 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string,
|
||||
totalDur int
|
||||
}
|
||||
var cur _shotGroup
|
||||
var prevScene string
|
||||
for _, sh := range allShots {
|
||||
shDur := parseMMSSToSeconds(sh.EndTime) - parseMMSSToSeconds(sh.StartTime)
|
||||
if shDur <= 0 {
|
||||
shDur = 1
|
||||
}
|
||||
// 场景切换 → 必须分段(不同场景不能合并到同一段)
|
||||
sceneChanged := len(cur.shots) > 0 && sh.Scene != prevScene
|
||||
// 计算加入当前镜头后的候选组总时长和提示词长度
|
||||
candShots := make([]domain.Shot, len(cur.shots)+1)
|
||||
copy(candShots, cur.shots)
|
||||
@@ -481,7 +487,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string,
|
||||
exceedDur := len(cur.shots) > 0 && candDur > effectiveMax
|
||||
exceedChars := promptMaxChars > 0 && len([]rune(candPrompt)) > promptMaxChars
|
||||
|
||||
if exceedDur || exceedChars {
|
||||
if exceedDur || exceedChars || sceneChanged {
|
||||
// 当前组已满,保存并开启新组
|
||||
taskGroups = append(taskGroups, _taskGroup{
|
||||
promptText: buildFinalPrompt(domain.ShotsToText(cur.shots)),
|
||||
@@ -492,6 +498,7 @@ func createPendingTasks(ctx context.Context, dramaId, epId int64, script string,
|
||||
cur.shots = candShots
|
||||
cur.totalDur = candDur
|
||||
}
|
||||
prevScene = sh.Scene
|
||||
}
|
||||
if len(cur.shots) > 0 {
|
||||
taskGroups = append(taskGroups, _taskGroup{
|
||||
|
||||
@@ -87,8 +87,10 @@ func (c *GenerationContext) buildRefIndex() error {
|
||||
}
|
||||
b64, err := imageFileToBase64(ch.PortraitPath)
|
||||
if err != nil {
|
||||
fmt.Printf("buildRefIndex 警告: 无法读取演员[%s]的形象图片: %v (path=%s)\n", ch.Name, err, ch.PortraitPath)
|
||||
continue
|
||||
}
|
||||
|
||||
if existing, ok := charIdx[ch.Name]; ok {
|
||||
return fmt.Errorf("演员名冲突: '%s' (已有形象路径 %s,重复 %s)", ch.Name, existing, ch.PortraitPath)
|
||||
}
|
||||
@@ -106,6 +108,7 @@ func (c *GenerationContext) buildRefIndex() error {
|
||||
}
|
||||
b64, err := imageFileToBase64(sc.ImagePath)
|
||||
if err != nil {
|
||||
fmt.Printf("buildRefIndex 警告: 无法读取场景[%s]的图片: %v (path=%s)\n", sc.Name, err, sc.ImagePath)
|
||||
continue
|
||||
}
|
||||
if _, ok := sceneIdx[sc.Name]; ok {
|
||||
@@ -125,6 +128,7 @@ func (c *GenerationContext) buildRefIndex() error {
|
||||
}
|
||||
b64, err := imageFileToBase64(p.ImagePath)
|
||||
if err != nil {
|
||||
fmt.Printf("buildRefIndex 警告: 无法读取道具[%s]的图片: %v (path=%s)\n", p.Name, err, p.ImagePath)
|
||||
continue
|
||||
}
|
||||
if _, ok := propIdx[p.Name]; ok {
|
||||
|
||||
Reference in New Issue
Block a user