This commit is contained in:
2026-07-28 12:57:57 +08:00
parent a0fe0c76c3
commit 472d1a6521
4 changed files with 29 additions and 46 deletions
+12
View File
@@ -103,6 +103,18 @@
- 只有在"画面上看不到这段内容,但需要配音解说才能让观众理解"时 → 才放入 `narration`
- 如果描述的是背景中的声音元素(观众听到但画面中无人提及)→ 放入 `ambientSound`
### ⚠️ 最高优先级规则:画外音与台词严格分离
剧情描述中如果出现以下标记,必须原文原样放入对应的字段,**绝对不能混淆**:
| 剧情描述中的标记 | 必须放入的字段 | 绝对禁止放入的字段 |
|---|---|---|
| 标记为「画外音」「旁白」「内心独白」「OS(Overlapping Sound)」的内容 | `narration` | `dialogue` |
| 标记为「台词」「对白」「说」「喊道」「低语」「质问」等角色亲口说出的话 | `dialogue` | `narration` |
| 引号「」括起来的角色说话内容 | `dialogue` | `narration` |
**这条规则的优先级别高于其他所有规则。** 即使你觉得某段画外音放到 dialogue 中"更合适",也必须严格按照原文标记放。原文标记为画外音的,只能出现在 `narration` 字段,出现在 `dialogue` 字段就是错误。
**常见错误**(禁止):
```
错误1:把画面描述放入旁白
BIN
View File
Binary file not shown.
+3 -3
View File
@@ -592,13 +592,13 @@ func (s *episodeService) buildScriptGenUserInput(ctx context.Context, d *entity.
b.WriteString("\n")
}
b.WriteString("【输出格式】\nJSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS\n- endTime: 结束时间(格式MM:SS\n- event: 画面描述——观众在屏幕上直接看到的一切。场景环境、角色动作、表情变化、物体位置等。**这是视频模型生成画面的唯一依据,所有画面内容必须写在这里**,严禁把画面描述放入 narration 字段。不包含旁白和台词。\n- dialogue: 主台词——角色在画面中亲口说出的对白。多人对话用「角色名:台词」格式。旁白内心独白不属于这里。如果本镜头无人说话,留空即可。\n- narration: 旁白配音——需要配音演员念出来的解说文字。必须是**通过画面无法直接传达**的信息(如角色内心独白、故事背景交代、时间跳跃说明等)。**如果一段文字描述的是观众能直接看到的画面内容,它就不属于旁白,必须放进 event 字段。** 本镜头无旁白时留空即可。\n- ambientSound: 环境音效——背景中的声音元素,如风声、雨声、脚步声、警笛声、门铃声、人群嘈杂等。后期音效合成,不需要配音。本镜头无特殊环境音时留空即可。\n- shotSize: 景别,从以下标准类型中选择一种:远景、全景、中景、近景、特写\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择。**推断规则:从本镜头的event(画面出现了谁)、dialogue(看\"角色名:台词\"格式中的角色名)、narration(旁白提到了谁)三个字段中提取出演人物,三者取并集。画面中没出现、没说话、旁白也没提到的人物绝不能出现在characters中**\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具名称数组,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n**朗读时长约束(硬性规则)**:每个镜头的时长(endTime−startTime)必须足以容纳其台词(dialogue)和旁白(narration)的配音。参考中文语速" + speedPrompt + ",台词+旁白的预估朗读时间(总字数÷对应语速值)不得大于镜头时长的80%。如果当前时长不足,必须延长endTime,并按需调整后续镜头的startTime。\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n")
b.WriteString("【输出格式】\nJSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS\n- endTime: 结束时间(格式MM:SS\n- event: 画面描述——观众在屏幕上直接看到的一切。场景环境、角色动作、表情变化、物体位置等。**这是视频模型生成画面的唯一依据,所有画面内容必须写在这里**,严禁把画面描述放入 narration 字段。不包含旁白和台词。\n- dialogue: 主台词——角色在画面中亲口说出的对白。多人对话用「角色名:台词」格式。**旁白内心独白、画外音不属于这里。如果剧情描述将其标记为「画外音/旁白」,即使它看起来像一段话,也绝对禁止放入 dialogue。** 如果本镜头无人说话,留空即可。\n- narration: 旁白配音——需要配音演员念出来的解说文字。必须是**通过画面无法直接传达**的信息(如角色内心独白、故事背景交代、时间跳跃说明等)。**如果一段文字描述的是观众能直接看到的画面内容,它就不属于旁白,必须放进 event 字段。** 本镜头无旁白时留空即可。\n\n**最高优先级规则:剧情描述中明确标记为「画外音/旁白」的内容,必须原文原样放入 narration 字段,绝对不能放入 dialogue 字段。dialogue 字段只收录角色亲口说出的对白。这条规则的优先级高于其他所有规则。**\n- ambientSound: 环境音效——背景中的声音元素,如风声、雨声、脚步声、警笛声、门铃声、人群嘈杂等。后期音效合成,不需要配音。本镜头无特殊环境音时留空即可。\n- shotSize: 景别,从以下标准类型中选择一种:远景、全景、中景、近景、特写\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择。**推断规则:从本镜头的event(画面出现了谁)、dialogue(看\"角色名:台词\"格式中的角色名)、narration(旁白提到了谁)三个字段中提取出演人物,三者取并集。画面中没出现、没说话、旁白也没提到的人物绝不能出现在characters中**\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具名称数组,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n**朗读时长约束(硬性规则)**:每个镜头的时长(endTime−startTime)必须足以容纳其台词(dialogue)和旁白(narration)的配音。参考中文语速" + speedPrompt + ",台词+旁白的预估朗读时间(总字数÷对应语速值)不得大于镜头时长的80%。如果当前时长不足,必须延长endTime,并按需调整后续镜头的startTime。\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n")
// 根据剧情描述是否已有结构化信息,动态调整创作规范
if isStructuredDescription(description) {
fmt.Fprintf(&b, "【创作规范】\n\n【总时长约束】本集固定时长 %d 秒,所有镜头时长之和必须等于此值(含朗读时间)。\n\n【重要】剧情描述已包含结构化镜头信息,执行「只补缺不创作」模式:\n\n1. 严格保留已有内容 —— 剧情描述中每个镜头已包含的字段(场景、天气、运镜、开始时间、结束时间、台词、画外音、环境音、景别、人物、道具)直接原样保留到输出 JSON 的对应字段,不做任何改写或润色。\n\n2. 只补充缺失字段 —— 检查每个镜头缺少哪些字段,仅对确实缺失的字段进行补充:\n - 缺少景别(shotSize)→ 按镜头内容从「远景/全景/中景/近景/特写」中选择合适的\n - 缺少运镜(cameraMovement)→ 按场景类型从「固定镜头/推/拉/摇/移/跟/升/降/旋转/晃动/航拍」中选择\n - 缺少台词/画外音/环境音 → 若剧情描述中已写明则原样保留,未写就留空\n - 缺少时间 → 按总时长和镜头顺序合理分配\n - scene/characters/props 缺失 → 从「可用场景/演员/道具」中选择匹配的\n - characters 推断依据:当前镜头的 event(画面描述)中出现的角色、dialogue(台词\"角色名:\"前缀)中的发言角色、narration 中涉及的角色,三者必选其一。角色既没在画面中出现、也没台词、旁白也没涉及,就不能写入 characters\n\n3. 禁止新增镜头或情节 —— 严格按照剧情描述中已有的镜头分段输出,不得自行拆分、合并、增加剧情描述中没有的镜头或动作。剧情描述中有几个镜头就输出几个镜头。\n\n - 如果剧情内容超过 %d 秒容量,优先保证所有台词完整保留,非核心画面描述可适当精简。\n\n4. 朗读时长约束 —— 有台词(dialogue)或旁白(narration)的镜头必须遵守:\n - 镜头时长(endTimestartTime)必须足以容纳配音内容。参考中文语速"+speedPrompt+"。\n - 要求:endTimestartTime ≥ (len(dialogue)+len(narration))÷对应语速值,即至少满足朗读所需时间。\n - 如果当前endTime不够,必须向后延长,并相应调整后续镜头的startTime。\n\n5. 口型适配规则 —— 有台词的镜头(dialogue 不为空)必须遵守:\n - shotSize 必须为中景或近景(不能是远景、全景或特写)。\n - 说话角色必须面向或侧向镜头,不能背对镜头说话。\n", d.EpisodeDuration, d.EpisodeDuration)
fmt.Fprintf(&b, "【创作规范】\n\n【总时长约束】本集固定时长 %d 秒,所有镜头时长之和必须等于此值(含朗读时间)。\n\n【重要】剧情描述已包含结构化镜头信息,执行「只补缺不创作」模式:\n\n1. 严格保留已有内容 —— 剧情描述中每个镜头已包含的字段(场景、天气、运镜、开始时间、结束时间、台词、画外音、环境音、景别、人物、道具)直接原样保留到输出 JSON 的对应字段,不做任何改写或润色。\n - **特别重要的硬性规则:剧情描述中标记为「画外音/旁白」的内容,必须原样放入对应镜头的 narration 字段,绝对不能放入 dialogue 字段。** 即使你觉得某段画外音写成 dialogue 很合理,也必须严格遵循原文标记。这条规则优先级高于所有其他规则。\n\n2. 只补充缺失字段 —— 检查每个镜头缺少哪些字段,仅对确实缺失的字段进行补充:\n - 缺少景别(shotSize)→ 按镜头内容从「远景/全景/中景/近景/特写」中选择合适的\n - 缺少运镜(cameraMovement)→ 按场景类型从「固定镜头/推/拉/摇/移/跟/升/降/旋转/晃动/航拍」中选择\n - 缺少台词/画外音/环境音 → 若剧情描述中已写明则原样保留,未写就留空\n - 缺少时间 → 按总时长和镜头顺序合理分配\n - scene/characters/props 缺失 → 从「可用场景/演员/道具」中选择匹配的\n - characters 推断依据:当前镜头的 event(画面描述)中出现的角色、dialogue(台词\"角色名:\"前缀)中的发言角色、narration 中涉及的角色,三者必选其一。角色既没在画面中出现、也没台词、旁白也没涉及,就不能写入 characters\n\n3. 禁止新增镜头或情节 —— 严格按照剧情描述中已有的镜头分段输出,不得自行拆分、合并、增加剧情描述中没有的镜头或动作。剧情描述中有几个镜头就输出几个镜头。\n\n - 如果剧情内容超过 %d 秒容量,优先保证所有台词完整保留,非核心画面描述可适当精简。\n\n4. 朗读时长约束 —— 有台词(dialogue)或旁白(narration)的镜头必须遵守:\n - 镜头时长(endTimestartTime)必须足以容纳配音内容。参考中文语速"+speedPrompt+"。\n - 要求:endTimestartTime ≥ (len(dialogue)+len(narration))÷对应语速值,即至少满足朗读所需时间。\n - 如果当前endTime不够,必须向后延长,并相应调整后续镜头的startTime。\n\n5. 口型适配规则 —— 有台词的镜头(dialogue 不为空)必须遵守:\n - shotSize 必须为中景或近景(不能是远景、全景或特写)。\n - 说话角色必须面向或侧向镜头,不能背对镜头说话。\n", d.EpisodeDuration, d.EpisodeDuration)
} else {
b.WriteString(fmt.Sprintf("【创作规范】\n\n1. 剧情完整性【核心规则】—— 剧情描述是唯一创作依据,严格按每一句话、每一个细节逐拍还原。\n - 剧情描述中出现的每一句角色说的话,必须一字不差地写入对应镜头的dialogue字段。严禁把台词\"翻译\"成事件描述。\n - 剧情描述中的每一个具体细节(人物的动作、表情反应、对话、环境互动等)都必须忠实地保留在对应镜头的event、dialogue或narration字段中,不能省略、概括或改写为泛化描述。\n - 所有镜头时长之和应等于 %d 秒(50秒至少20个镜头,20-25个镜头为最佳密度)。\n\n2. 场景一致性 —— 每个镜头的 scene 字段值必须与 event 中画面描述的地点一致。\n - 如果 event 描述角色在\"实验室内\",scene 必须是该实验室的名称,不能写成其他地点。\n - 如果角色从一个场景移动到另一个场景,必须先切换 scene(如从「街道」切到「室内」),再描述新地点中的动作。\n\n3. 人物引用规则(硬性规则)—— characters 字段必须从本镜头的画面描述、台词、旁白中推断:\n - event 画面描述中提到了谁 → 谁就必须在 characters 中\n - dialogue 中的发言角色(\"角色名:台词\"格式中的角色名)→ 该角色必须在 characters 中\n - narration 中涉及的角色 → 该角色必须在 characters 中\n - 以上三条取并集。不在画面中、不说话、旁白也不涉及的角色即使在整部剧里很重要,本镜头也不能写入 characters。\n\n4. 景别与动作匹配规则 —— 按镜头内容选择正确的 shotSize\n - shotSize=特写:面部表情、微表情、眼神变化、细节反应(手部小动作、物品细节)。不能用于动作展示。\n - shotSize=近景:对话、上半身互动、情绪交流。能看清面部表情和手势。\n - shotSize=中景:日常动作、手持物体、多人交互。膝盖以上,既有动作又有空间感。\n - shotSize=全景:连贯动作、奔跑、打斗、空间关系。展示全身动作和环境关系。\n - shotSize=远景:环境交代、空镜过渡、场景建立。没有人或人很小,用于转场。\n\n5. 朗读时长约束(硬性规则)—— 有台词(dialogue)或旁白(narration)的镜头必须遵守:\n - 镜头时长(endTimestartTime)必须足以容纳配音内容。参考中文语速"+speedPrompt+"。\n - 要求:endTimestartTime ≥ (len(dialogue)+len(narration))÷对应语速值,即至少满足朗读所需时间。\n - 如果当前endTime不够,必须向后延长,并相应调整后续镜头的startTime。\n\n6. 运镜搭配规则 —— 按场景类型选择 cameraMovement\n - 对话/情绪交流 → 固定镜头或缓推\n - 动作/打斗 → 跟、摇、移\n - 人物进入/离开画面 → 跟、移\n - 情绪揭示 → 推\n - 环境展示 → 摇、升降、航拍\n - 紧张/混乱 → 晃动\n - 固定镜头是默认选项,无特殊需要不使用复杂运镜。\n\n7. 口型适配规则 —— 有台词的镜头(dialogue 不为空)必须遵守:\n - shotSize 必须为中景或近景(不能是远景、全景或特写)。\n - 说话角色必须面向或侧向镜头,不能背对镜头说话。\n - 不说话只做反应的角色可用其他景别。\n", d.EpisodeDuration))
b.WriteString(fmt.Sprintf("【创作规范】\n\n1. 剧情完整性【核心规则】—— 剧情描述是唯一创作依据,严格按每一句话、每一个细节逐拍还原。\n - 剧情描述中出现的每一句角色说的话,必须一字不差地写入对应镜头的dialogue字段。严禁把台词\"翻译\"成事件描述。\n - 剧情描述中的每一个具体细节(人物的动作、表情反应、对话、环境互动等)都必须忠实地保留在对应镜头的event、dialogue或narration字段中,不能省略、概括或改写为泛化描述。\n - 所有镜头时长之和应等于 %d 秒(50秒至少20个镜头,20-25个镜头为最佳密度)。\n\n2. 场景一致性 —— 每个镜头的 scene 字段值必须与 event 中画面描述的地点一致。\n - 如果 event 描述角色在\"实验室内\",scene 必须是该实验室的名称,不能写成其他地点。\n - 如果角色从一个场景移动到另一个场景,必须先切换 scene(如从「街道」切到「室内」),再描述新地点中的动作。\n\n3. 人物引用规则(硬性规则)—— characters 字段必须从本镜头的画面描述、台词、旁白中推断:\n - event 画面描述中提到了谁 → 谁就必须在 characters 中\n - dialogue 中的发言角色(\"角色名:台词\"格式中的角色名)→ 该角色必须在 characters 中\n - narration 中涉及的角色 → 该角色必须在 characters 中\n - 以上三条取并集。不在画面中、不说话、旁白也不涉及的角色即使在整部剧里很重要,本镜头也不能写入 characters。\n\n4. 景别与动作匹配规则 —— 按镜头内容选择正确的 shotSize\n - shotSize=特写:面部表情、微表情、眼神变化、细节反应(手部小动作、物品细节)。不能用于动作展示。\n - shotSize=近景:对话、上半身互动、情绪交流。能看清面部表情和手势。\n - shotSize=中景:日常动作、手持物体、多人交互。膝盖以上,既有动作又有空间感。\n - shotSize=全景:连贯动作、奔跑、打斗、空间关系。展示全身动作和环境关系。\n - shotSize=远景:环境交代、空镜过渡、场景建立。没有人或人很小,用于转场。\n\n5. 朗读时长约束(硬性规则)—— 有台词(dialogue)或旁白(narration)的镜头必须遵守:\n - 镜头时长(endTimestartTime)必须足以容纳配音内容。参考中文语速"+speedPrompt+"。\n - 要求:endTimestartTime ≥ (len(dialogue)+len(narration))÷对应语速值,即至少满足朗读所需时间。\n - 如果当前endTime不够,必须向后延长,并相应调整后续镜头的startTime。\n\n6. 运镜搭配规则 —— 按场景类型选择 cameraMovement\n - 对话/情绪交流 → 固定镜头或缓推\n - 动作/打斗 → 跟、摇、移\n - 人物进入/离开画面 → 跟、移\n - 情绪揭示 → 推\n - 环境展示 → 摇、升降、航拍\n - 紧张/混乱 → 晃动\n - 固定镜头是默认选项,无特殊需要不使用复杂运镜。\n\n7. **画外音与台词严格分离(硬性规则)—— 如果某段文字是角色内心独白、故事背景介绍、时间跳跃说明等内容(画外音/旁白),必须放入 narration 字段,绝对不能放入 dialogue 字段。dialogue 只放角色亲口说出的对白。这条规则优先级最高。**\n\n8. 口型适配规则 —— 有台词的镜头(dialogue 不为空)必须遵守:\n - shotSize 必须为中景或近景(不能是远景、全景或特写)。\n - 说话角色必须面向或侧向镜头,不能背对镜头说话。\n - 不说话只做反应的角色可用其他景别。\n", d.EpisodeDuration))
}
b.WriteString(agent.GetShotDurationPrompt(ctx, d.Type))
b.WriteString(agent.GetStylePrompt(ctx, d.Type))
+14 -43
View File
@@ -1620,6 +1620,8 @@ func (s *generationService) StartVideoPoller(ctx context.Context) {
g.Log().Infof(ctx, "视频轮询池已初始化,并发数=%d", poolSize)
go func() {
// 启动后立即执行一次,不等第一个 tick
s.pollPendingVideos(ctx)
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
g.Log().Infof(ctx, "后台视频轮询器已启动,间隔 15s")
@@ -1924,53 +1926,22 @@ func (s *generationService) pollVideoTaskOnce(ctx context.Context, modelCfg *Mer
return "", fmt.Errorf("task error: %s - %s", result.Code, result.Message)
}
func (s *generationService) waitForSegmentVideo(ctx context.Context, d *entity.Drama, ep *entity.Episode, taskId int64) error {
modelCfg := UserModelConfigService.GetMergedConfig(ctx, d.UserId, "video")
if modelCfg.ApiKey == "" || modelCfg.BaseUrl == "" {
return fmt.Errorf("video model not configured or query URL is empty")
}
task, err := dao.GenerationTask.GetOne(ctx, taskId)
if err != nil || task == nil {
return fmt.Errorf("get task failed: %w", err)
}
if task.VideoTaskId == "" {
return fmt.Errorf("task %d has no video_task_id yet", taskId)
}
videoTaskID := task.VideoTaskId
maxAttempts := 50 // 约 5 分钟(每次轮询间隔 6s)
for attempt := 0; attempt < maxAttempts; attempt++ {
videoURL, err := s.pollVideoTaskOnce(ctx, modelCfg, videoTaskID)
if err == nil && videoURL != "" {
// 视频就绪,下载到本地
dramaTitle := sanitizeDirName(d.Title)
safeEp := sanitizeDirName(ep.Title)
localPath, dlErr := s.downloadToLocal(ctx, videoURL, dramaTitle, "产出视频",
fmt.Sprintf("%s_seg_%d.mp4", safeEp, task.SegmentIdx))
if dlErr != nil {
g.Log().Warningf(ctx, "serial wait: task %d segment %d video download failed, using remote url: %v", task.Id, task.SegmentIdx+1, dlErr)
_ = dao.GenerationTask.UpdateFields(ctx, task.Id, g.Map{"video_url": videoURL, "video_task_id": ""})
} else {
g.Log().Infof(ctx, "串行等待: 任务 %d 第%d段视频已下载: %s", task.Id, task.SegmentIdx+1, localPath)
_ = dao.GenerationTask.UpdateFields(ctx, task.Id, g.Map{"video_url": localPath, "video_task_id": ""})
}
// 不直接调 API 轮询,由全局 StartVideoPoller(每 15 秒)负责,
// 这里只查数据库等待 video_url 被 poller 写入
for {
task, err := dao.GenerationTask.GetOne(ctx, taskId)
if err != nil || task == nil {
return fmt.Errorf("get task failed: %w", err)
}
if task.VideoUrl != "" {
g.Log().Infof(ctx, "串行等待: task %d segment %d video ready: %s", task.Id, task.SegmentIdx+1, task.VideoUrl)
return nil
}
if err != nil {
errMsg := strings.ToLower(err.Error())
// RUNNING/PENDING 是正常处理中,继续轮询
// "duration" 类错误也是可重试的
if strings.Contains(errMsg, "running") || strings.Contains(errMsg, "pending") {
g.Log().Infof(ctx, "serial wait: segment %d video task still %s, retrying...", task.SegmentIdx+1, err.Error())
} else if strings.Contains(errMsg, "duration") {
g.Log().Warningf(ctx, "serial wait: segment %d video task duration error, retrying: %v", task.SegmentIdx+1, err)
} else {
return fmt.Errorf("poll video task failed: %w", err)
}
if task.Status == consts.TaskStatusFailed {
return fmt.Errorf("segment %d video generation failed", task.SegmentIdx+1)
}
time.Sleep(6 * time.Second)
time.Sleep(15 * time.Second)
}
return fmt.Errorf("serial wait timeout: task %d segment %d not ready after %d polls", task.Id, task.SegmentIdx+1, maxAttempts)
}
// ==================== Video Generation ====================