feat(prompts, webui, llm): 新增影视解说功能及配套更新

- 新增影视解说专属提示词模块,覆盖剧情分析、文案生成、片段规划、脚本匹配与修复全流程
- 注册影视解说模块到全局提示词系统,更新初始化加载逻辑
- 重构Tavily搜索服务,拆分通用搜索函数适配短剧和影视两类作品
- 更新WebUI界面,新增影视解说配置项、多语言翻译与版本号展示
- 升级项目版本号从0.7.9到0.8.1
- 调整LLM服务与适配器逻辑,支持自定义prompt分类适配不同解说类型
- 完善相关工具类与单元测试,覆盖影视解说场景调用流程
This commit is contained in:
viccy 2026-06-08 00:30:37 +08:00
parent ca4f2bf594
commit d10c2ff7c5
19 changed files with 1186 additions and 147 deletions

View File

@ -31,6 +31,7 @@ class SubtitleAnalyzer:
custom_prompt: Optional[str] = None,
temperature: Optional[float] = 1.0,
provider: Optional[str] = None,
prompt_category: str = "short_drama_narration",
):
"""
初始化字幕分析器
@ -49,6 +50,7 @@ class SubtitleAnalyzer:
self.base_url = base_url
self.temperature = temperature
self.provider = provider or self._detect_provider()
self.prompt_category = prompt_category or "short_drama_narration"
# 设置自定义提示词(如果提供)
self.custom_prompt = custom_prompt
@ -94,7 +96,7 @@ class SubtitleAnalyzer:
else:
# 使用新的提示词管理系统,正确传入参数
prompt = PromptManager.get_prompt(
category="short_drama_narration",
category=self.prompt_category,
name="plot_analysis",
parameters={"subtitle_content": subtitle_content}
)
@ -365,12 +367,12 @@ class SubtitleAnalyzer:
def _render_prompt(self, name: str, parameters: Dict[str, Any]) -> Tuple[str, Optional[str]]:
prompt = PromptManager.get_prompt(
category="short_drama_narration",
category=self.prompt_category,
name=name,
parameters=parameters,
)
prompt_object = PromptManager.get_prompt_object(
category="short_drama_narration",
category=self.prompt_category,
name=name,
)
return prompt, prompt_object.get_system_prompt()
@ -838,7 +840,8 @@ def analyze_subtitle(
temperature: float = 1.0,
save_result: bool = False,
output_path: Optional[str] = None,
provider: Optional[str] = None
provider: Optional[str] = None,
prompt_category: str = "short_drama_narration",
) -> Dict[str, Any]:
"""
分析字幕内容的便捷函数
@ -865,7 +868,8 @@ def analyze_subtitle(
model=model,
base_url=base_url,
custom_prompt=custom_prompt,
provider=provider
provider=provider,
prompt_category=prompt_category,
)
logger.debug(f"使用模型: {analyzer.model} 开始分析, 温度: {analyzer.temperature}")
# 分析字幕
@ -900,6 +904,7 @@ def generate_narration_script(
provider: Optional[str] = None,
narration_language: str = "简体中文(中国)",
drama_genre: str = "逆袭/复仇",
prompt_category: str = "short_drama_narration",
) -> Dict[str, Any]:
"""
根据剧情分析生成解说文案的便捷函数
@ -926,7 +931,8 @@ def generate_narration_script(
api_key=api_key,
model=model,
base_url=base_url,
provider=provider
provider=provider,
prompt_category=prompt_category,
)
# 生成解说文案
@ -957,6 +963,7 @@ def generate_narration_copy(
provider: Optional[str] = None,
narration_language: str = "简体中文(中国)",
drama_genre: str = "逆袭/复仇",
prompt_category: str = "short_drama_narration",
) -> Dict[str, Any]:
"""生成可供用户审核修改的解说正文。"""
analyzer = SubtitleAnalyzer(
@ -965,6 +972,7 @@ def generate_narration_copy(
model=model,
base_url=base_url,
provider=provider,
prompt_category=prompt_category,
)
return analyzer.generate_narration_copy(
@ -990,6 +998,7 @@ def match_narration_copy_to_script(
narration_language: str = "简体中文(中国)",
drama_genre: str = "逆袭/复仇",
original_sound_ratio: int = 30,
prompt_category: str = "short_drama_narration",
) -> Dict[str, Any]:
"""将用户审核后的解说正文匹配到字幕时间戳。"""
analyzer = SubtitleAnalyzer(
@ -998,6 +1007,7 @@ def match_narration_copy_to_script(
model=model,
base_url=base_url,
provider=provider,
prompt_category=prompt_category,
)
return analyzer.match_narration_copy_to_script(
@ -1025,6 +1035,7 @@ def repair_narration_script(
provider: Optional[str] = None,
narration_language: str = "简体中文(中国)",
drama_genre: str = "逆袭/复仇",
prompt_category: str = "short_drama_narration",
) -> Dict[str, Any]:
"""根据校验错误修复解说文案的便捷函数。"""
analyzer = SubtitleAnalyzer(
@ -1033,6 +1044,7 @@ def repair_narration_script(
model=model,
base_url=base_url,
provider=provider,
prompt_category=prompt_category,
)
return analyzer.repair_narration_script(

View File

@ -198,11 +198,19 @@ class VisionAnalyzerAdapter:
class SubtitleAnalyzerAdapter:
"""字幕分析器适配器"""
def __init__(self, api_key: str, model: str, base_url: str, provider: str = None):
def __init__(
self,
api_key: str,
model: str,
base_url: str,
provider: str = None,
prompt_category: str = "short_drama_narration",
):
self.api_key = api_key
self.model = model
self.base_url = base_url
self.provider = provider or "openai"
self.prompt_category = prompt_category or "short_drama_narration"
def _run_async_safely(self, coro_func, *args, **kwargs):
"""安全地运行异步协程"""
@ -228,12 +236,12 @@ class SubtitleAnalyzerAdapter:
def _render_prompt(self, name: str, parameters: Dict[str, Any]) -> tuple[str, Optional[str]]:
prompt = PromptManager.get_prompt(
category="short_drama_narration",
category=self.prompt_category,
name=name,
parameters=parameters,
)
prompt_object = PromptManager.get_prompt_object(
category="short_drama_narration",
category=self.prompt_category,
name=name,
)
return prompt, prompt_object.get_system_prompt()
@ -466,6 +474,7 @@ class SubtitleAnalyzerAdapter:
subtitle_content=subtitle_content,
provider=self.provider,
temperature=1.0,
prompt_category=self.prompt_category,
api_key=self.api_key,
api_base=self.base_url
)

View File

@ -4,6 +4,7 @@ from unittest import mock
from app.services.llm.migration_adapter import SubtitleAnalyzerAdapter
from app.services.llm.unified_service import UnifiedLLMService
from app.services.prompts import PromptManager
class SubtitleAnalyzerAdapterPipelineTests(unittest.TestCase):
@ -30,6 +31,31 @@ class SubtitleAnalyzerAdapterPipelineTests(unittest.TestCase):
self.assertIn("家庭伦理", call.call_args.kwargs["prompt"])
self.assertNotIn("response_format", call.call_args.kwargs)
def test_generate_narration_copy_can_use_film_tv_prompt_category(self):
self.assertTrue(PromptManager.exists("film_tv_narration", "narration_copy"))
adapter = SubtitleAnalyzerAdapter(
api_key="sk-test",
model="test-model",
base_url="https://example.test/v1",
provider="openai",
prompt_category="film_tv_narration",
)
with mock.patch.object(adapter, "_run_async_safely", return_value="他发现证据不对,真正的凶手另有其人。") as call:
result = adapter.generate_narration_copy(
short_name="测试电影",
plot_analysis="主角发现证据疑点。",
subtitle_content="# 视频 1: 1.mp4\n00:00:01,000 --> 00:00:04,000\n证据不对。",
temperature=0.7,
narration_language="简体中文(中国)",
drama_genre="悬疑/犯罪",
)
self.assertEqual("success", result["status"])
self.assertIn("影视解说正文创作任务", call.call_args.kwargs["prompt"])
self.assertIn("用户选择的影视类型", call.call_args.kwargs["prompt"])
self.assertNotIn("短剧解说正文创作任务", call.call_args.kwargs["prompt"])
def test_match_narration_copy_to_script_uses_json_prompt_with_selected_type(self):
adapter = SubtitleAnalyzerAdapter(
api_key="sk-test",

View File

@ -194,6 +194,7 @@ class UnifiedLLMService:
async def analyze_subtitle(subtitle_content: str,
provider: Optional[str] = None,
temperature: float = 1.0,
prompt_category: str = "short_drama_narration",
validate_output: bool = True,
**kwargs) -> str:
"""
@ -214,12 +215,12 @@ class UnifiedLLMService:
"""
try:
prompt = PromptManager.get_prompt(
category="short_drama_narration",
category=prompt_category,
name="plot_analysis",
parameters={"subtitle_content": subtitle_content},
)
prompt_object = PromptManager.get_prompt_object(
category="short_drama_narration",
category=prompt_category,
name="plot_analysis",
)
system_prompt = prompt_object.get_system_prompt()

View File

@ -56,11 +56,13 @@ __all__ = [
def initialize_prompts():
"""初始化提示词模块,注册所有提示词"""
from . import documentary
from . import film_tv_narration
from . import short_drama_editing
from . import short_drama_narration
# 注册各模块的提示词
documentary.register_prompts()
film_tv_narration.register_prompts()
short_drama_editing.register_prompts()
short_drama_narration.register_prompts()

View File

@ -0,0 +1,48 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
@Project: NarratoAI
@File : __init__.py
@Description: 影视解说提示词模块
"""
from .plot_analysis import PlotAnalysisPrompt
from .narration_copy import NarrationCopyPrompt
from .segment_planning import SegmentPlanningPrompt
from .script_generation import ScriptGenerationPrompt
from .script_matching import ScriptMatchingPrompt
from .script_repair import ScriptRepairPrompt
from ..manager import PromptManager
def register_prompts():
"""注册影视解说相关的提示词"""
plot_analysis_prompt = PlotAnalysisPrompt()
PromptManager.register_prompt(plot_analysis_prompt, is_default=True)
narration_copy_prompt = NarrationCopyPrompt()
PromptManager.register_prompt(narration_copy_prompt, is_default=True)
segment_planning_prompt = SegmentPlanningPrompt()
PromptManager.register_prompt(segment_planning_prompt, is_default=True)
script_generation_prompt = ScriptGenerationPrompt()
PromptManager.register_prompt(script_generation_prompt, is_default=True)
script_matching_prompt = ScriptMatchingPrompt()
PromptManager.register_prompt(script_matching_prompt, is_default=True)
script_repair_prompt = ScriptRepairPrompt()
PromptManager.register_prompt(script_repair_prompt, is_default=True)
__all__ = [
"PlotAnalysisPrompt",
"NarrationCopyPrompt",
"SegmentPlanningPrompt",
"ScriptGenerationPrompt",
"ScriptMatchingPrompt",
"ScriptRepairPrompt",
"register_prompts",
]

View File

@ -0,0 +1,88 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
@Project: 影视解说-解说文案
@File : narration_copy.py
@Description: 生成可供用户审核修改的影视解说正文
"""
from ..base import ParameterizedPrompt, PromptMetadata, ModelType, OutputFormat
class NarrationCopyPrompt(ParameterizedPrompt):
"""影视解说正文生成提示词"""
def __init__(self):
metadata = PromptMetadata(
name="narration_copy",
category="film_tv_narration",
version="v1.0",
description="基于剧情理解和字幕生成可审核修改的影视解说正文,不绑定时间戳",
model_type=ModelType.TEXT,
output_format=OutputFormat.TEXT,
tags=["影视", "解说文案", "电影解说", "剧情承接", "用户审核"],
parameters=["drama_name", "drama_genre", "plot_analysis", "subtitle_content", "narration_language"],
)
super().__init__(metadata, required_parameters=["drama_name", "plot_analysis", "subtitle_content"])
self._system_prompt = (
"你是一位影视解说文案创作者。你只输出可供用户审核修改的解说正文,"
"不要输出JSON、时间戳、编号、标题、解释或Markdown。"
)
def get_template(self) -> str:
return """# 影视解说正文创作任务
## 目标
为影视作品${drama_name}创作一份可直接给用户审核修改的解说文案正文此阶段不做画面匹配不输出时间戳
## 剧情理解材料
<plot>
${plot_analysis}
</plot>
## 原始字幕
<subtitles>
${subtitle_content}
</subtitles>
## 输出语言
<narration_language>
${narration_language}
</narration_language>
## 用户选择的影视类型
<drama_genre>
${drama_genre}
</drama_genre>
## 类型写作规则
必须按用户选择的影视类型调整表达重点不要自行改判类型
- 剧情/情感突出人物选择关系裂痕命运压力和情绪余波
- 悬疑/犯罪突出线索疑点动机误导和未揭开的真相
- 动作/冒险突出目标危险升级身体对抗和关键抉择
- 喜剧/轻松突出误会反差节奏包袱和人物可爱处
- 科幻/奇幻突出设定规则未知威胁世界观反差和代价
- 历史/战争突出时代处境阵营选择牺牲和局势变化
- 恐怖/惊悚突出异常细节压迫感未知危险和心理悬念
- 自定义类型严格服从用户填写的类型方向
## 开头钩子公式
开头必须使用人物困境 + 反常信息 + 悬念问题
1. 先点出主角或关键人物正在面对什么压力
2. 再抛出一个违背常识关系突变或危险升级的信息
3. 最后留下观众想继续看的问题他为什么这样做谁在撒谎这场选择会把所有人推向哪里
## 写作规则
1. 必须使用 ${narration_language}
2. 严格基于剧情理解和字幕事实不编造核心情节身份结局
3. 先写清楚人物动机和因果链再写情绪金句不要只堆形容词
4. 每句话只表达一个信息点适合后续按句匹配画面
5. 句子尽量短单句优先 15-35 信息复杂时拆成多句
6. 2-3 句要有明确承接让观众知道为什么从上一幕来到下一幕
7. 总长度控制在 350-750 短素材取下限长素材取上限
8. 不要使用编号项目符号章节标题或括号说明
## 输出要求
只输出解说正文不要输出 JSON时间戳代码块或任何解释"""

View File

@ -0,0 +1,99 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
@Project: 影视解说-剧情分析
@File : plot_analysis.py
@Description: 影视剧情分析提示词
"""
from ..base import TextPrompt, PromptMetadata, ModelType, OutputFormat
class PlotAnalysisPrompt(TextPrompt):
"""影视剧情分析提示词"""
def __init__(self):
metadata = PromptMetadata(
name="plot_analysis",
category="film_tv_narration",
version="v1.0",
description="结合字幕和可选联网检索上下文,输出适合影视解说脚本生成的结构化剧情理解",
model_type=ModelType.TEXT,
output_format=OutputFormat.TEXT,
tags=["影视", "电影", "电视剧", "剧情分析", "字幕解析", "解说脚本素材"],
parameters=["subtitle_content"],
)
super().__init__(metadata)
self._system_prompt = (
"你是一位专业的影视解说策划和剧作分析师。请输出克制、结构化、"
"可直接供下游影视解说脚本生成使用的剧情理解材料。"
)
def get_template(self) -> str:
return """# 角色
你是一位专业的影视解说策划和剧作分析师你的输出不是给观众看的成片文案而是给下游影视解说脚本生成器使用的结构化剧情理解材料
# 输入说明
下面的输入可能只包含一个视频的原始字幕也可能包含多个视频文件的字幕也可能同时包含联网检索结果和原始字幕
- 联网检索结果只能用于辅助识别作品名称人物关系时代背景公开剧情梗概
- 原始字幕是唯一可信的当前片段事实来源
- 如果联网检索结果与字幕冲突必须以字幕为准
- 如果联网检索结果包含当前字幕尚未出现的后续剧情只能放在字幕未覆盖/需谨慎信息不能写进当前剧情事实
- 多个视频字幕会以视频 1: 文件名视频 2: 文件名等标题分隔时间戳均为对应视频内部时间不是拼接后的累计时间
# 核心任务
请基于输入完成剧情理解目标是帮助后续生成高质量影视解说脚本
1. 识别作品名称当前字幕范围视频来源联网检索辅助信息和字幕事实边界
2. 统一人物称呼梳理人物关系动机和当前场景中的立场变化
3. 120-220 字概括当前字幕覆盖的剧情不提前剧透字幕未出现的内容
4. 按视频来源和字幕时间顺序拆分关键剧情段落并为每段标注准确 video_id / video_name / 时间戳
5. 提炼解说创作可用的开场钩子人物困境情绪转折信息反转名场面和建议保留原声片段
# 强制输出规则
1. 禁止输出寒暄解释身份或好的我将等聊天式开场
2. 禁止编造字幕中没有的具体事件对白关系进展或结局
3. 时间戳必须直接来自对应视频字幕无法确定时写字幕未明确不要猜测
4. 多视频场景下必须明确每段来自哪个视频文件禁止把不同视频的同名时间戳混在一起
5. 人名必须统一优先采用联网检索中的正式名称如果字幕写法不同在人物表中保留字幕称呼
6. 内容要简洁客观可复用避免散文化长段落
7. 必须严格按照下面的 Markdown 格式输出不要添加额外章节
# 输出格式
## 一、基础识别
- 作品名称[如输入可判断则填写否则写未知]
- 当前字幕范围[开始时间戳] --> [结束时间戳]无法确定则写字幕未明确
- 视频来源[列出视频编号文件名和各自字幕时间范围单视频也要写]
- 联网检索确认[仅写可辅助理解的公开信息没有联网结果则写未启用/未提供]
- 字幕内实际出现[列出当前字幕真实出现的关键事实2-5 ]
- 字幕未覆盖/需谨慎信息[列出联网结果提到但当前字幕未发生的内容没有则写]
## 二、人物与关系
| 统一称呼 | 字幕称呼 | 身份/关系 | 当前动机/立场 | 确定性 |
|---|---|---|---|---|
| [人物名] | [字幕原文称呼] | [身份或关系] | [在当前片段中的目标压力或转变] | 字幕明确/联网辅助/合理推断 |
## 三、整体剧情概括
[120-220 只概括当前字幕覆盖的剧情必须包含核心冲突人物动机场景推进和当前悬念]
## 四、分段剧情解析
| 视频 | 时间戳 | 段落主题 | 剧情事件 | 叙事功能 |
|---|---|---|---|---|
| [video_id + video_name] | [开始] --> [结束] | [简短主题] | [当前段落发生了什么] | [铺垫/冲突升级/人物塑造/反转/悬念/情绪爆发/名场面等] |
## 五、解说创作重点
- 开场钩子[用一句话指出最适合开场抓人的冲突谜题或人物困境]
- 核心冲突[当前片段最主要的矛盾]
- 情绪转折/信息反转[ 1-3 没有则写无明显]
- 名场面/高光对白[ 1-3 没有则写无明显]
- 悬念点[当前片段留下的疑问或后续期待]
- 建议保留原声片段
1. [video_id + video_name + 时间戳][保留理由如果没有合适原声无明显]
## 六、联网信息校验
- 可用于辅助理解的信息[联网结果中可帮助理解当前字幕的信息没有则写]
- 与字幕不一致或字幕未覆盖的信息[必须列出不要混入当前剧情事实没有则写]
# 输入内容
${subtitle_content}"""

View File

@ -0,0 +1,152 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
@Project: 影视解说-文案画面匹配
@File : script_generation.py
@Description: 影视解说脚本生成提示词
"""
from ..base import ParameterizedPrompt, PromptMetadata, ModelType, OutputFormat
class ScriptGenerationPrompt(ParameterizedPrompt):
"""影视解说脚本生成提示词"""
def __init__(self):
metadata = PromptMetadata(
name="script_generation",
category="film_tv_narration",
version="v1.0",
description="基于已规划片段生成高质量影视解说脚本,重点补足人物动机、信息承接和剧情因果",
model_type=ModelType.TEXT,
output_format=OutputFormat.JSON,
tags=["影视", "解说脚本", "文案生成", "原声片段", "悬念", "名场面"],
parameters=[
"drama_name",
"drama_genre",
"plot_analysis",
"subtitle_content",
"segment_plan",
"narration_language",
],
)
super().__init__(metadata, required_parameters=["drama_name", "plot_analysis", "segment_plan"])
self._system_prompt = (
"你是一位影视解说文案写手。你必须严格按照JSON格式输出"
"只能补充picture和narration不能改动上游片段规划中的_id、video_id、video_name、timestamp和OST。"
)
def get_template(self) -> str:
return """# 影视解说脚本文案生成任务
## 任务目标
为影视作品${drama_name}生成最终可剪辑解说脚本片段已经由上游规划完成你只能补充 picture narration不能改变片段来源和时间戳
## 输入材料
### 剧情概述
<plot>
${plot_analysis}
</plot>
### 已规划片段(必须逐项照抄结构字段)
<segment_plan>
${segment_plan}
</segment_plan>
### 原始字幕(含视频编号和精确时间戳)
<subtitles>
${subtitle_content}
</subtitles>
### 解说台词语言
<narration_language>
${narration_language}
</narration_language>
### 用户选择的影视类型
<drama_genre>
${drama_genre}
</drama_genre>
字幕可能来自多个视频文件每个字幕分段标题会以视频 1: 文件名视频 2: 文件名等形式标识来源
生成脚本时必须把每个片段绑定到对应视频来源时间戳表示该视频文件内部的局部时间不是把多个视频拼接后的全局时间
所有 OST=0 narration 字段必须使用上方指定的解说台词语言输出不要因为原始字幕是其他语言就切回字幕原语言
OST=1 的原声片段 narration 字段必须继续使用播放原片+序号格式不要翻译这个固定标记
## 绝对绑定规则
1. 输出 items 数量顺序和 _id 必须与 segment_plan 完全一致
2. 每个 item _idvideo_idvideo_nametimestampOST 必须逐字复制 segment_plan不得新增删除合并拆分或改动
3. 你只能补充 picture narration 两个字段
4. OST=1 narration 必须写成播放原片+_id例如 _id 5 时写播放原片5
5. OST=0 narration 必须使用 ${narration_language}并严格基于剧情和字幕不虚构字幕外的具体事件
## 叙事连续性要求
- 你必须把每个 OST=0 当成观众理解剧情的桥不能只概括当前画面
- 每个 OST=0 narration 要尽量回答上一段发生了什么人物为什么这么做这一段带来什么新信息或新危机
- video_id 或跨时间大跳跃时OST=0 必须明确补出承接句例如真正危险的不是这场争吵而是他终于发现证据指向了身边人
- 原声片段前后的 OST=0 要解释原声的重要性避免观众只看到对白片段合集
- 如果 segment_plan 中有 story_roleintenttransition 字段必须利用它们组织 narration但不要把这些字段输出到最终 JSON
- 结尾 OST=0 要留下后续阻力真相疑问或人物选择如果结尾是 OST=1则前一个 OST=0 必须提前点出这段原声会把矛盾推向哪里
## 开头钩子要求
- 第一段必须是 OST=0 解说钩子不能直接播放原片
- 开头用人物困境 + 反常信息 + 悬念问题主角压力 + 异常线索/关系突变 + 后续疑问
- 写法示例方向他以为这只是一次普通问询可一句话之后所有证据都指向了他最信任的人
- 示例只用于理解公式必须基于当前字幕事实原创不要夸大到字幕没有的情节
## 解说密度与画面节奏
- OST=0 文案必须能被当前 timestamp 的画面承载解说字数 / 5 = 所需视频秒数估算
- 如果画面只有 6 就不要写 80 应压缩到约 30 或依赖 segment_plan 选择更长画面
- 优先短句单句只表达一个信息点不要把人物介绍前因反转和悬念全塞进一个短画面
- 长信息要拆成多段每段只承担一个叙事功能让画面节奏跟上解说
## 用户选择类型文案规则
影视类型由用户手动选择为 ${drama_genre}不得自行改判必须按对应方向写
- 剧情/情感突出人物选择关系裂痕命运压力和情绪余波
- 悬疑/犯罪突出线索疑点动机误导和未揭开的真相
- 动作/冒险突出目标危险升级身体对抗和关键抉择
- 喜剧/轻松突出误会反差节奏包袱和人物可爱处
- 科幻/奇幻突出设定规则未知威胁世界观反差和代价
- 历史/战争突出时代处境阵营选择牺牲和局势变化
- 恐怖/惊悚突出异常细节压迫感未知危险和心理悬念
- 自定义类型严格服从用户填写的类型方向
## 文案质量要求
- 开场片段要有强钩子直接点出冲突疑点或人物困境
- 每段解说优先 25-90 具体长度必须服从画面时长短画面宁可少说不要密集灌信息
- 可以使用可真正的问题是而他还不知道这句话背后危险已经开始靠近等影视解说转折语但不要堆砌
- picture 要描述画面和人物状态便于后期识别素材
- 少用孤立信息句多用承接句不要让观众感觉剧情突然跳场
- 不要解释规则不要输出 Markdown不要输出代码块
## 输出格式
请严格按照以下JSON格式输出绝不添加任何其他文字说明或代码块标记
{
"items": [
{
"_id": 1,
"video_id": 1,
"video_name": "1.mp4",
"timestamp": "00:00:01,000-00:00:05,500",
"picture": "男主站在审讯室门口,神情紧张地看向桌上的证据袋",
"narration": "他以为这只是一次普通问询,可桌上的证据却把所有矛头指向了自己。",
"OST": 0
},
{
"_id": 2,
"video_id": 1,
"video_name": "1.mp4",
"timestamp": "00:00:05,500-00:00:08,000",
"picture": "警官低声质问,男主沉默不语",
"narration": "播放原片2",
"OST": 1
}
]
}
现在请基于以上要求为影视作品${drama_name}创作解说脚本"""

View File

@ -0,0 +1,131 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
@Project: 影视解说-文案画面匹配
@File : script_matching.py
@Description: 将用户审核后的影视解说文案匹配到字幕时间戳并生成最终剪辑脚本
"""
from ..base import ParameterizedPrompt, PromptMetadata, ModelType, OutputFormat
class ScriptMatchingPrompt(ParameterizedPrompt):
"""影视解说文案画面匹配提示词"""
def __init__(self):
metadata = PromptMetadata(
name="script_matching",
category="film_tv_narration",
version="v1.0",
description="将审核后的影视解说文案按叙事节奏拆分并匹配到字幕时间戳生成最终剪辑JSON",
model_type=ModelType.TEXT,
output_format=OutputFormat.JSON,
tags=["影视", "画面匹配", "剪辑脚本", "时间戳", "用户文案"],
parameters=[
"drama_name",
"drama_genre",
"plot_analysis",
"subtitle_content",
"narration_copy",
"narration_language",
"original_sound_ratio",
],
)
super().__init__(
metadata,
required_parameters=["drama_name", "subtitle_content", "narration_copy"],
)
self._system_prompt = (
"你是一位懂影视叙事节奏的剪辑师。你必须严格输出JSON"
"核心任务是把用户审核后的解说文案逐句匹配到最合适的原视频字幕时间戳。"
)
def get_template(self) -> str:
return """# 影视解说文案画面匹配任务
## 目标
用户已经审核并修改了解说文案请根据这份文案和原始字幕生成最终可剪辑 JSON 脚本
## 作品名
${drama_name}
## 剧情理解材料
<plot>
${plot_analysis}
</plot>
## 用户审核后的解说文案
<narration_copy>
${narration_copy}
</narration_copy>
## 原始字幕(含视频编号和局部时间戳)
<subtitles>
${subtitle_content}
</subtitles>
## 输出语言
<narration_language>
${narration_language}
</narration_language>
## 用户选择的影视类型
<drama_genre>
${drama_genre}
</drama_genre>
## 用户选择的原片占比
<original_sound_ratio>
${original_sound_ratio}%
</original_sound_ratio>
## 匹配流程
1. 先按句号问号感叹号省略号切分解说文案得到候选解说句
2. 逗号只在明显分割两个动作场景观点或描述对象时切分不要切出没有独立意义的碎片
3. 不要求每个候选句都单独输出为 OST=0可以合并压缩相邻候选句作为剧情桥段但不能改变用户文案的核心意思
4. 为每个解说片段寻找最匹配的原始字幕画面优先选择能表达该句核心含义人物状态或信息转折的画面
5. 使用公式估算所需画面时长所需秒数 = 解说字数 / 5匹配画面时长尽量接近误差优先控制在 ±0.5
6. 如果一句解说太长必须拆成多个 OST=0 片段分别匹配不同或连续画面
7. timestamp 必须使用对应 video_id 内部局部时间戳不得换算为多个视频拼接后的累计时间
8. 同一 video_id 内时间段不得交叉或重叠
9. 第一段必须是 OST=0 解说钩子不能直接播放原片
10. OST=1 原声片段的总时长占比要尽量接近用户选择的 ${original_sound_ratio}%这里按最终 items timestamp 总时长估算不按片段数量估算
11. 不要自行判断或改写影视类型画面匹配和 picture 描述要服务用户选择的 ${drama_genre} 叙事重点
## 原片占比规则
- ${original_sound_ratio}% = 0% 不要输出 OST=1全部使用解说承接
- ${original_sound_ratio}% 10%-30% 只保留关键对白信息反转情绪爆发或名场面原声
- ${original_sound_ratio}% 40%-60% 解说负责串联因果原片负责承载关键场面和对白
- ${original_sound_ratio}% 70%-90% 以原片对白和表演为主解说只做开场钩子转场桥和必要补充
- 如果原片占比与第一段必须 OST=0冲突优先保证第一段是 OST=0然后在后续片段提高 OST=1 时长占比
- 选择高原片占比时可以把用户文案合并成更少的 OST=0 桥段不要为了逐句使用文案而压低原片占比
## 字段规则
- _id 1 开始连续递增
- video_id来自字幕分段标题例如视频 2就填 2
- video_name对应视频文件名必须从字幕分段标题提取
- timestamp格式为 "HH:MM:SS,mmm-HH:MM:SS,mmm"
- picture描述匹配画面中人物动作情绪场景和关键道具
- narrationOST=0 时填写用户文案片段OST=1 时填写播放原片+_id
- OST解说片段填 0原声片段填 1
## 输出格式
只输出严格 JSON
{
"items": [
{
"_id": 1,
"video_id": 1,
"video_name": "1.mp4",
"timestamp": "00:00:01,000-00:00:06,000",
"picture": "主角站在走廊尽头,回头看向紧闭的房门",
"narration": "他以为自己终于逃出了那间房,可真正的危险,其实才刚刚醒来。",
"OST": 0
}
]
}
现在请基于用户审核后的解说文案生成最终剪辑脚本"""

View File

@ -0,0 +1,96 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
@Project: 影视解说-脚本修复
@File : script_repair.py
@Description: 影视解说脚本校验失败后的JSON修复提示词
"""
from ..base import ParameterizedPrompt, PromptMetadata, ModelType, OutputFormat
class ScriptRepairPrompt(ParameterizedPrompt):
"""影视解说脚本修复提示词"""
def __init__(self):
metadata = PromptMetadata(
name="script_repair",
category="film_tv_narration",
version="v1.0",
description="根据确定性校验错误修复影视解说脚本JSON优先修正时间戳、视频来源和格式问题",
model_type=ModelType.TEXT,
output_format=OutputFormat.JSON,
tags=["影视", "解说脚本", "JSON修复", "时间戳校验", "多视频"],
parameters=[
"drama_name",
"drama_genre",
"plot_analysis",
"subtitle_content",
"invalid_script",
"validation_errors",
"narration_language",
],
)
super().__init__(
metadata,
required_parameters=["drama_name", "subtitle_content", "invalid_script", "validation_errors"],
)
self._system_prompt = (
"你是一位影视解说脚本JSON修复器。你只能根据校验错误修复JSON"
"必须输出严格JSON不能输出解释、Markdown或代码块。"
)
def get_template(self) -> str:
return """# 影视解说脚本修复任务
## 修复目标
下面的影视作品${drama_name}解说脚本未通过剪辑校验请只根据校验错误和字幕内容修复它输出一个完整可剪辑的 JSON
## 剧情理解材料
<plot>
${plot_analysis}
</plot>
## 校验错误
<validation_errors>
${validation_errors}
</validation_errors>
## 当前无效脚本
<invalid_script>
${invalid_script}
</invalid_script>
## 可用字幕窗口
<subtitles>
${subtitle_content}
</subtitles>
## 解说台词目标语言
<narration_language>
${narration_language}
</narration_language>
## 用户选择的影视类型
<drama_genre>
${drama_genre}
</drama_genre>
## 修复规则
1. 只输出 JSON不要任何解释标题Markdown 或代码块
2. 输出根对象必须是 {"items": [...]}
3. 每个 item 必须包含 _idvideo_idvideo_nametimestamppicturenarrationOST
4. video_idvideo_name timestamp 必须来自对应字幕窗口不得把不同视频的同名时间戳混用
5. 同一 video_id 内片段不得交叉或重叠
6. OST=1 narration 必须是播放原片+序号OST=0 narration 必须使用 ${narration_language}
7. 禁止连续 3 个或更多 OST=1必须插入或改写 OST=0 解说片段承接剧情
8. video_id 切换前后不能都是 OST=1必须至少有一个 OST=0 片段解释场景和剧情为什么切换
9. OST=0 narration 要补足人物动机信息承接和因果转折不要只概括当前画面
10. 第一段必须是 OST=0 解说钩子人物困境 + 反常信息 + 悬念问题不要直接播放原片
11. OST=0 文案必须匹配画面时长解说字数 / 5 = 所需视频秒数估算过密时要缩短文案延长时间戳或拆成多个片段
12. 不要自行改判影视类型如需改写 narration必须按用户选择的 ${drama_genre} 保持表达重点
13. 尽量保留原脚本中没有错误的片段无法修复的片段可以删除但剩余片段必须重新按 1 开始编号
请输出修复后的完整 JSON"""

View File

@ -0,0 +1,103 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
@Project: 影视解说-片段规划
@File : segment_planning.py
@Description: 影视解说脚本片段规划提示词
"""
from ..base import ParameterizedPrompt, PromptMetadata, ModelType, OutputFormat
class SegmentPlanningPrompt(ParameterizedPrompt):
"""影视解说片段规划提示词"""
def __init__(self):
metadata = PromptMetadata(
name="segment_planning",
category="film_tv_narration",
version="v1.0",
description="基于剧情理解和原始字幕规划可剪辑片段,优先保证影视叙事连续性和原声解说节奏",
model_type=ModelType.TEXT,
output_format=OutputFormat.JSON,
tags=["影视", "解说脚本", "片段规划", "时间戳", "多视频", "原声"],
parameters=["drama_name", "drama_genre", "plot_analysis", "subtitle_content", "narration_language"],
)
super().__init__(metadata, required_parameters=["drama_name", "plot_analysis", "subtitle_content"])
self._system_prompt = (
"你是一位影视解说剪辑规划师。你的任务是从字幕中选择可剪辑片段,"
"必须严格输出JSON不能写解说文案不能输出Markdown或额外说明。"
)
def get_template(self) -> str:
return """# 影视解说片段规划任务
## 目标
为影视作品${drama_name}规划一组可直接剪辑的视频片段你只负责选片段和标注用途不写最终解说台词
## 剧情理解材料
<plot>
${plot_analysis}
</plot>
## 原始字幕(含视频编号和局部时间戳)
<subtitles>
${subtitle_content}
</subtitles>
## 解说台词目标语言
<narration_language>
${narration_language}
</narration_language>
## 用户选择的影视类型
<drama_genre>
${drama_genre}
</drama_genre>
## 叙事规划目标
你不是在挑精彩片段合集而是在规划一条观众能顺着看懂的影视解说故事线必须先想清楚人物处境 -> 事件触发 -> 关系或信息变化 -> 新危机 -> 悬念的因果链再选片段
## 开场钩子规则
第一段必须是 OST=0 解说开场不要直接播放原片开头参考人物困境 + 反常信息 + 悬念问题的公式
- 先给人物一个明确压力被误解被追捕被迫选择失去重要之人发现异常线索
- 再给一个反常信息熟人背叛证据失效规则被打破危险提前出现
- 最后抛出问题谁在说谎真相藏在哪里这次选择会付出什么代价
- 不要照抄示例要基于字幕事实改写成当前作品自己的钩子
## 规划规则
1. 只能使用原始字幕中真实存在的视频编号视频文件名和时间范围
2. timestamp 必须是对应 video_id 内部的局部时间戳禁止换算成多个视频拼接后的累计时间
3. 同一个 video_id 内的片段不得交叉或重叠尽量按故事顺序排列
4. 每个片段必须推动主线解释人物动机制造情绪转折承接原声或保留关键对白
5. OST=1 表示保留原声适合关键对白情绪爆发真相揭露名场面和反转OST=0 表示后续需要配解说
6. 原声片段单段优先控制在 3-10 解说片段可以更长但必须能从字幕范围中定位
7. 影视类型由用户手动选择为 ${drama_genre}不得自行改判选片段时优先服务该类型的主要看点
8. 禁止连续 3 个或更多 OST=1 1-2 个原声片段后必须安排 OST=0 解说片段承接剧情
9. video_id 切换前后必须至少有一个 OST=0 片段作为剧情桥段解释为什么从上一场转到下一场
10. 每个 OST=0 片段必须承担明确叙事功能开场钩子人物介绍因果过渡信息解释情绪转折冲突升级结尾悬念
11. 不要跳过关键因果关系变化线索发现危机升级必须有画面或解说桥段承接
12. 结尾优先选择能留下新问题新危险或人物选择的片段不要只停在原声对白堆叠上
13. 解说画面必须给足时长解说字数 / 5 = 所需视频秒数预估短画面不要承载长解说
## 输出格式
只输出严格 JSON
{
"segments": [
{
"_id": 1,
"video_id": 1,
"video_name": "1.mp4",
"timestamp": "00:00:01,000-00:00:05,500",
"OST": 0,
"story_role": "开场钩子",
"intent": "点出主角困境和反常线索,制造继续观看的疑问",
"transition": "从当前场景切入人物压力,引出下一段关键对白"
}
]
}
现在请规划影视作品${drama_name}的解说片段"""

View File

@ -35,15 +35,37 @@ def search_short_drama(
timeout: int = DEFAULT_TIMEOUT,
) -> dict[str, Any]:
"""Search web context for a short drama name with Tavily."""
short_name = str(short_name or "").strip()
if not short_name:
raise TavilySearchError("短剧名称不能为空")
return search_story_context(
short_name,
api_key,
search_keywords="短剧 剧情 介绍 人物 结局",
empty_name_message="短剧名称不能为空",
search_depth=search_depth,
max_results=max_results,
timeout=timeout,
)
def search_story_context(
title: str,
api_key: str | None = None,
*,
search_keywords: str = "剧情 介绍 人物 结局",
empty_name_message: str = "作品名称不能为空",
search_depth: str = DEFAULT_SEARCH_DEPTH,
max_results: int = DEFAULT_MAX_RESULTS,
timeout: int = DEFAULT_TIMEOUT,
) -> dict[str, Any]:
"""Search web context for a story title with Tavily."""
title = str(title or "").strip()
if not title:
raise TavilySearchError(empty_name_message)
api_key = (api_key or os.getenv("TAVILY_API_KEY") or "").strip()
if not api_key:
raise TavilySearchError("Tavily API Key 未配置")
query = f"{short_name} 短剧 剧情 介绍 人物 结局"
query = f"{title} {search_keywords}".strip()
payload = {
"query": query,
"search_depth": search_depth or DEFAULT_SEARCH_DEPTH,
@ -77,13 +99,12 @@ def search_short_drama(
raise TavilySearchError("Tavily 返回内容不是有效 JSON") from exc
logger.info(
"Tavily 剧检索完成: query={}, results={}",
"Tavily 检索完成: query={}, results={}",
query,
len(data.get("results") or []),
)
return data
def format_search_context(search_data: dict[str, Any], *, max_chars: int = 6000) -> str:
"""Format Tavily response into compact LLM context."""
if not search_data:

View File

@ -1 +1 @@
0.7.9
0.8.1

View File

@ -129,6 +129,11 @@ def tr(key):
return loc.get("Translation", {}).get(key, key)
def get_help_text():
"""返回带当前项目版本号的帮助文案"""
return tr("Get Help").replace("🎉🎉🎉", f" v{config.project_version}")
def render_generate_button():
"""渲染生成按钮和处理逻辑"""
if st.button(tr("Generate Video"), use_container_width=True, type="primary"):
@ -588,7 +593,7 @@ def main():
logger.warning(f"资源初始化时出现警告: {e}")
st.title(f"Narrato:blue[AI]:sunglasses: 📽️")
st.write(tr("Get Help"))
st.write(get_help_text())
# 首先渲染不依赖PyTorch的UI部分
# 渲染基础设置面板

View File

@ -15,6 +15,10 @@ from app.utils import utils, check_script
from webui.tools.generate_script_docu import generate_script_docu
from webui.tools.generate_script_short import generate_script_short
from webui.tools.generate_short_summary import (
FILM_TV_PROMPT_CATEGORY,
FILM_TV_SEARCH_KEYWORDS,
SHORT_DRAMA_PROMPT_CATEGORY,
SHORT_DRAMA_SEARCH_KEYWORDS,
analyze_short_drama_plot,
generate_script_short_sunmmary,
generate_short_drama_narration_copy,
@ -22,6 +26,12 @@ from webui.tools.generate_short_summary import (
SCRIPT_TABLE_BASE_COLUMNS = ["_id", "video_id", "video_name", "timestamp", "picture", "narration", "OST"]
MODE_FILE = "file_selection"
MODE_AUTO = "auto"
MODE_SHORT = "short"
MODE_SHORT_SUMMARY = "summary"
MODE_FILM_SUMMARY = "film_summary"
SUMMARY_SCRIPT_MODES = {MODE_SHORT_SUMMARY, MODE_FILM_SUMMARY}
VIDEO_UPLOAD_TYPES = ["mp4", "mov", "avi", "flv", "mkv", "mpeg4"]
VIDEO_GLOB_PATTERNS = [f"*.{suffix}" for suffix in VIDEO_UPLOAD_TYPES]
SHORT_DRAMA_NARRATION_LANGUAGE_OPTIONS = [
@ -66,7 +76,64 @@ SHORT_DRAMA_TYPE_VALUES = {
"urban_emotion": "都市情感",
"period_rural": "年代/乡村",
}
FILM_TV_TYPE_OPTIONS = [
("drama_emotion", "剧情/情感"),
("suspense_crime", "悬疑/犯罪"),
("action_adventure", "动作/冒险"),
("comedy_light", "喜剧/轻松"),
("sci_fi_fantasy", "科幻/奇幻"),
("history_war", "历史/战争"),
("horror_thriller", "恐怖/惊悚"),
("custom", "自定义"),
]
FILM_TV_TYPE_VALUES = {
"drama_emotion": "剧情/情感",
"suspense_crime": "悬疑/犯罪",
"action_adventure": "动作/冒险",
"comedy_light": "喜剧/轻松",
"sci_fi_fantasy": "科幻/奇幻",
"history_war": "历史/战争",
"horror_thriller": "恐怖/惊悚",
}
SHORT_DRAMA_ORIGINAL_SOUND_RATIO_OPTIONS = list(range(0, 100, 10))
SUMMARY_MODE_CONFIGS = {
MODE_FILM_SUMMARY: {
"mode_label_key": "Film TV Narration",
"session_prefix": "film_tv",
"prompt_category": FILM_TV_PROMPT_CATEGORY,
"search_keywords": FILM_TV_SEARCH_KEYWORDS,
"web_search_context_description": "影视作品名称、人物关系、剧情背景和公开剧情梗概",
"empty_title_message_key": "Please enter film/tv title before web search",
"title_label_key": "影视名称",
"type_label_key": "影视类型",
"custom_type_label_key": "自定义影视类型",
"custom_type_placeholder_key": "例如:悬疑犯罪",
"custom_type_empty_key": "请输入自定义影视类型",
"narration_copy_label_key": "影视解说文案",
"type_options": FILM_TV_TYPE_OPTIONS,
"type_values": FILM_TV_TYPE_VALUES,
"default_type": "drama_emotion",
"default_type_value": "剧情/情感",
},
MODE_SHORT_SUMMARY: {
"mode_label_key": "Short Drama Summary",
"session_prefix": "short_drama",
"prompt_category": SHORT_DRAMA_PROMPT_CATEGORY,
"search_keywords": SHORT_DRAMA_SEARCH_KEYWORDS,
"web_search_context_description": "短剧名称、人物关系、剧情背景和公开剧情梗概",
"empty_title_message_key": "Please enter short drama name before web search",
"title_label_key": "短剧名称",
"type_label_key": "短剧类型",
"custom_type_label_key": "自定义短剧类型",
"custom_type_placeholder_key": "例如:豪门虐恋",
"custom_type_empty_key": "请输入自定义短剧类型",
"narration_copy_label_key": "短剧解说文案",
"type_options": SHORT_DRAMA_TYPE_OPTIONS,
"type_values": SHORT_DRAMA_TYPE_VALUES,
"default_type": "counterattack",
"default_type_value": "逆袭/复仇",
},
}
def _normalize_video_paths(paths):
@ -201,20 +268,47 @@ def _short_drama_plot_analysis_signature(subtitle_paths, video_theme, web_search
)
def _resolve_short_drama_narration_language():
selected_language = st.session_state.get('short_drama_narration_language_option', 'zh-CN')
custom_language = str(st.session_state.get('short_drama_custom_narration_language', '') or '').strip()
def _summary_mode_config(script_path=None):
script_path = script_path or st.session_state.get('video_clip_json_path', MODE_FILM_SUMMARY)
return SUMMARY_MODE_CONFIGS.get(script_path, SUMMARY_MODE_CONFIGS[MODE_SHORT_SUMMARY])
def _summary_state_key(summary_config, suffix):
return f"{summary_config['session_prefix']}_{suffix}"
def _resolve_summary_narration_language(summary_config):
selected_language = st.session_state.get(
_summary_state_key(summary_config, "narration_language_option"),
"zh-CN",
)
custom_language = str(
st.session_state.get(_summary_state_key(summary_config, "custom_narration_language"), "") or ""
).strip()
if selected_language == "custom" and custom_language:
return custom_language
return SHORT_DRAMA_NARRATION_LANGUAGE_VALUES.get(selected_language, "简体中文(中国)")
def _resolve_short_drama_type():
selected_type = st.session_state.get('short_drama_type_option', 'counterattack')
custom_type = str(st.session_state.get('short_drama_custom_type', '') or '').strip()
def _resolve_summary_type(summary_config):
selected_type = st.session_state.get(
_summary_state_key(summary_config, "type_option"),
summary_config["default_type"],
)
custom_type = str(
st.session_state.get(_summary_state_key(summary_config, "custom_type"), "") or ""
).strip()
if selected_type == "custom" and custom_type:
return custom_type
return SHORT_DRAMA_TYPE_VALUES.get(selected_type, "逆袭/复仇")
return summary_config["type_values"].get(selected_type, summary_config["default_type_value"])
def _resolve_short_drama_narration_language():
return _resolve_summary_narration_language(SUMMARY_MODE_CONFIGS[MODE_SHORT_SUMMARY])
def _resolve_short_drama_type():
return _resolve_summary_type(SUMMARY_MODE_CONFIGS[MODE_SHORT_SUMMARY])
def render_script_panel(tr):
@ -239,9 +333,9 @@ def render_script_panel(tr):
elif script_path == "short":
# 短剧混剪
render_short_generate_options(tr)
elif script_path == "summary":
# 短剧解说
short_drama_summary(tr)
elif script_path in SUMMARY_SCRIPT_MODES:
# 影视解说 / 短剧解说
summary_narration_panel(tr, _summary_mode_config(script_path))
else:
# 默认为空
pass
@ -252,15 +346,10 @@ def render_script_panel(tr):
def render_script_file(tr, params):
"""渲染脚本文件选择"""
# 定义功能模式
MODE_FILE = "file_selection"
MODE_AUTO = "auto"
MODE_SHORT = "short"
MODE_SUMMARY = "summary"
# 模式选项映射,按工作流优先级展示
mode_options = {
tr("Short Drama Summary"): MODE_SUMMARY,
tr("Film TV Narration"): MODE_FILM_SUMMARY,
tr("Short Drama Summary"): MODE_SHORT_SUMMARY,
tr("Auto Generate"): MODE_AUTO,
tr("Short Generate"): MODE_SHORT,
tr("Select/Upload Script"): MODE_FILE,
@ -279,6 +368,8 @@ def render_script_file(tr, params):
default_index = mode_keys.index(tr("Short Generate"))
elif current_path == "summary":
default_index = mode_keys.index(tr("Short Drama Summary"))
elif current_path == "film_summary":
default_index = mode_keys.index(tr("Film TV Narration"))
elif current_path:
default_index = mode_keys.index(tr("Select/Upload Script"))
else:
@ -354,8 +445,12 @@ def render_script_file(tr, params):
script_list.append((display_name, file['file']))
# 找到保存的脚本文件在列表中的索引
# 如果当前path是特殊值(auto/short/summary),则重置为空
saved_script_path = current_path if current_path not in [MODE_AUTO, MODE_SHORT, MODE_SUMMARY] else ""
# 如果当前path是特殊值(auto/short/summary/film_summary),则重置为空
saved_script_path = (
current_path
if current_path not in [MODE_AUTO, MODE_SHORT, MODE_SHORT_SUMMARY, MODE_FILM_SUMMARY]
else ""
)
selected_index = 0
for i, (_, path) in enumerate(script_list):
@ -558,7 +653,7 @@ def render_short_generate_options(tr):
渲染Short Generate模式下的特殊选项
在Short Generate模式下替换原有的输入框为自定义片段选项
"""
short_drama_summary(tr)
summary_narration_panel(tr, SUMMARY_MODE_CONFIGS[MODE_SHORT_SUMMARY])
# 显示自定义片段数量选择器
custom_clips = st.number_input(
tr("自定义片段"),
@ -605,8 +700,8 @@ def render_video_details(tr):
return video_theme, custom_prompt
def short_drama_summary(tr):
"""短剧解说 渲染视频主题和提示词"""
def summary_narration_panel(tr, summary_config):
"""影视/短剧解说 渲染视频主题和提示词"""
# 检查是否已经处理过字幕文件
if 'subtitle_file_processed' not in st.session_state:
st.session_state['subtitle_file_processed'] = False
@ -616,22 +711,27 @@ def short_drama_summary(tr):
current_subtitle_paths = _selected_subtitle_paths()
current_subtitle_path = current_subtitle_paths[0] if current_subtitle_paths else ''
web_search_key = _summary_state_key(summary_config, "web_search_enabled")
plot_button_key = _summary_state_key(summary_config, "plot_analysis_button")
plot_analysis_key = _summary_state_key(summary_config, "plot_analysis")
plot_source_key = _summary_state_key(summary_config, "plot_analysis_subtitle_path")
plot_signature_key = _summary_state_key(summary_config, "plot_analysis_signature")
st.markdown(
"""
f"""
<style>
.st-key-short_drama_web_search_enabled [data-testid="stMarkdownContainer"] {
.st-key-{web_search_key} [data-testid="stMarkdownContainer"] {{
display: none;
}
.st-key-short_drama_web_search_enabled [data-testid="stWidgetLabel"] {
}}
.st-key-{web_search_key} [data-testid="stWidgetLabel"] {{
min-width: 0;
transform: translateX(-1.2rem);
}
.st-key-short_drama_web_search_enabled label {
}}
.st-key-{web_search_key} label {{
align-items: center;
gap: 0.45rem;
}
.st-key-short_drama_web_search_enabled label > div:first-child {
}}
.st-key-{web_search_key} label > div:first-child {{
width: 3rem !important;
min-width: 3rem !important;
height: 1.55rem !important;
@ -640,29 +740,29 @@ def short_drama_summary(tr):
background: #e5e7eb !important;
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08) !important;
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease !important;
}
.st-key-short_drama_web_search_enabled label:hover > div:first-child {
}}
.st-key-{web_search_key} label:hover > div:first-child {{
background: #dbe3ef !important;
border-color: #b8c2d3 !important;
}
.st-key-short_drama_web_search_enabled label:has(input[aria-checked="true"]) > div:first-child {
}}
.st-key-{web_search_key} label:has(input[aria-checked="true"]) > div:first-child {{
border-color: transparent !important;
background: linear-gradient(135deg, #2563eb, #14b8a6) !important;
box-shadow: 0 6px 14px rgba(37, 99, 235, 0.22) !important;
}
.st-key-short_drama_web_search_enabled label > div:first-child > div {
}}
.st-key-{web_search_key} label > div:first-child > div {{
width: 1.05rem !important;
height: 1.05rem !important;
border-radius: 999px !important;
background: #ffffff !important;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.24) !important;
}
.st-key-short_drama_web_search_enabled button[aria-label^="Help for"] {
}}
.st-key-{web_search_key} button[aria-label^="Help for"] {{
color: #6b7280 !important;
}
.st-key-short_drama_web_search_enabled button[aria-label^="Help for"]:hover {
}}
.st-key-{web_search_key} button[aria-label^="Help for"]:hover {{
color: #2563eb !important;
}
}}
</style>
""",
unsafe_allow_html=True,
@ -670,18 +770,18 @@ def short_drama_summary(tr):
name_cols = st.columns([3.4, 1.1, 2], vertical_alignment="bottom")
with name_cols[0]:
video_theme = st.text_input(tr("短剧名称"))
video_theme = st.text_input(tr(summary_config["title_label_key"]))
with name_cols[1]:
web_search_enabled = st.toggle(
tr("联网搜索"),
key="short_drama_web_search_enabled",
key=web_search_key,
help=tr("Enable Web Search Help"),
disabled=not current_subtitle_path,
)
with name_cols[2]:
analyze_plot_clicked = st.button(
tr("剧情理解"),
key="short_drama_plot_analysis_button",
key=plot_button_key,
disabled=not current_subtitle_path,
use_container_width=True,
)
@ -693,15 +793,15 @@ def short_drama_summary(tr):
web_search_enabled,
_selected_video_paths(),
)
saved_signature = st.session_state.get('short_drama_plot_analysis_signature')
legacy_source = st.session_state.get('short_drama_plot_analysis_subtitle_path')
saved_signature = st.session_state.get(plot_signature_key)
legacy_source = st.session_state.get(plot_source_key)
if (
(saved_signature and saved_signature != current_signature)
or (legacy_source and legacy_source != current_subtitle_path)
):
st.session_state['short_drama_plot_analysis'] = ""
st.session_state['short_drama_plot_analysis_subtitle_path'] = ""
st.session_state['short_drama_plot_analysis_signature'] = ""
st.session_state[plot_analysis_key] = ""
st.session_state[plot_source_key] = ""
st.session_state[plot_signature_key] = ""
if analyze_plot_clicked:
with st.spinner(tr("Analyzing plot...")):
@ -713,23 +813,32 @@ def short_drama_summary(tr):
short_name=video_theme,
enable_web_search=web_search_enabled,
video_paths=_selected_video_paths(),
prompt_category=summary_config["prompt_category"],
search_keywords=summary_config["search_keywords"],
empty_title_message_key=summary_config["empty_title_message_key"],
web_search_context_description=summary_config["web_search_context_description"],
)
if plot_analysis:
st.session_state['short_drama_plot_analysis'] = plot_analysis
st.session_state['short_drama_plot_analysis_subtitle_path'] = current_subtitle_path
st.session_state['short_drama_plot_analysis_signature'] = current_signature
st.session_state[plot_analysis_key] = plot_analysis
st.session_state[plot_source_key] = current_subtitle_path
st.session_state[plot_signature_key] = current_signature
st.success(tr("Plot analysis completed"))
if st.session_state.get('short_drama_plot_analysis'):
if st.session_state.get(plot_analysis_key):
st.text_area(
tr("剧情理解结果"),
key="short_drama_plot_analysis",
key=plot_analysis_key,
height=240,
)
return video_theme
def short_drama_summary(tr):
"""短剧解说 渲染视频主题和提示词"""
return summary_narration_panel(tr, SUMMARY_MODE_CONFIGS[MODE_SHORT_SUMMARY])
def render_subtitle_preview(tr):
"""渲染可折叠的当前字幕预览;没有字幕时提示用户先转写或上传。"""
subtitle_paths = _selected_subtitle_paths()
@ -1295,63 +1404,88 @@ def render_script_buttons(tr, params):
button_name = tr("Generate Video Script")
elif script_path == "short":
button_name = tr("Generate Short Video Script")
elif script_path == "summary":
elif script_path in SUMMARY_SCRIPT_MODES:
button_name = tr("生成剪辑脚本")
elif script_path.endswith("json"):
button_name = tr("Load Video Script")
else:
button_name = tr("Please Select Script File")
if script_path == "summary":
config_cols = st.columns([1.15, 1.15, 0.9, 1.15, 1.15], vertical_alignment="bottom")
with config_cols[0]:
if script_path in SUMMARY_SCRIPT_MODES:
summary_config = _summary_mode_config(script_path)
type_option_key = _summary_state_key(summary_config, "type_option")
custom_type_key = _summary_state_key(summary_config, "custom_type")
original_sound_ratio_key = _summary_state_key(summary_config, "original_sound_ratio")
language_option_key = _summary_state_key(summary_config, "narration_language_option")
custom_language_key = _summary_state_key(summary_config, "custom_narration_language")
narration_copy_key = _summary_state_key(summary_config, "narration_copy")
type_options = [code for code, _ in summary_config["type_options"]]
if st.session_state.get(type_option_key) not in type_options:
st.session_state[type_option_key] = summary_config["default_type"]
language_options = [code for code, _ in SHORT_DRAMA_NARRATION_LANGUAGE_OPTIONS]
if st.session_state.get(language_option_key) not in language_options:
st.session_state[language_option_key] = "zh-CN"
show_custom_type = st.session_state.get(type_option_key, summary_config["default_type"]) == "custom"
show_custom_language = (
st.session_state.get(language_option_key, 'zh-CN') == "custom"
)
config_col_widths = [1.15]
if show_custom_type:
config_col_widths.append(1.15)
config_col_widths.extend([0.9, 1.15])
if show_custom_language:
config_col_widths.append(1.15)
config_cols = st.columns(config_col_widths, vertical_alignment="bottom")
config_col_index = 0
with config_cols[config_col_index]:
st.selectbox(
tr("短剧类型"),
options=[code for code, _ in SHORT_DRAMA_TYPE_OPTIONS],
format_func=lambda code: tr(dict(SHORT_DRAMA_TYPE_OPTIONS).get(code, code)),
key="short_drama_type_option",
tr(summary_config["type_label_key"]),
options=type_options,
format_func=lambda code: tr(dict(summary_config["type_options"]).get(code, code)),
key=type_option_key,
)
with config_cols[1]:
custom_type_disabled = (
st.session_state.get('short_drama_type_option', 'counterattack') != "custom"
)
st.text_input(
tr("自定义短剧类型"),
key="short_drama_custom_type",
placeholder=tr("例如:豪门虐恋"),
disabled=custom_type_disabled,
)
with config_cols[2]:
config_col_index += 1
if show_custom_type:
with config_cols[config_col_index]:
st.text_input(
tr(summary_config["custom_type_label_key"]),
key=custom_type_key,
placeholder=tr(summary_config["custom_type_placeholder_key"]),
)
config_col_index += 1
with config_cols[config_col_index]:
st.selectbox(
tr("原片占比"),
options=SHORT_DRAMA_ORIGINAL_SOUND_RATIO_OPTIONS,
format_func=lambda ratio: f"{ratio}%",
index=SHORT_DRAMA_ORIGINAL_SOUND_RATIO_OPTIONS.index(30),
key="short_drama_original_sound_ratio",
key=original_sound_ratio_key,
)
with config_cols[3]:
config_col_index += 1
with config_cols[config_col_index]:
st.selectbox(
tr("解说语言"),
options=[code for code, _ in SHORT_DRAMA_NARRATION_LANGUAGE_OPTIONS],
format_func=lambda code: tr(dict(SHORT_DRAMA_NARRATION_LANGUAGE_OPTIONS).get(code, code)),
key="short_drama_narration_language_option",
)
with config_cols[4]:
custom_language_disabled = (
st.session_state.get('short_drama_narration_language_option', 'zh-CN') != "custom"
)
st.text_input(
tr("自定义解说语言"),
key="short_drama_custom_narration_language",
placeholder=tr("例如:意大利语(意大利)"),
disabled=custom_language_disabled,
key=language_option_key,
)
config_col_index += 1
if show_custom_language:
with config_cols[config_col_index]:
st.text_input(
tr("自定义解说语言"),
key=custom_language_key,
placeholder=tr("例如:意大利语(意大利)"),
)
action_cols = st.columns([1, 1], vertical_alignment="bottom")
with action_cols[0]:
narration_copy_clicked = st.button(
tr("生成解说文案"),
key="short_drama_narration_copy_action",
key=_summary_state_key(summary_config, "narration_copy_action"),
disabled=not script_path,
use_container_width=True,
)
@ -1366,19 +1500,31 @@ def render_script_buttons(tr, params):
narration_copy_clicked = False
action_clicked = st.button(button_name, key="script_action", disabled=not script_path)
if script_path == "summary" and (narration_copy_clicked or action_clicked):
narration_language = _resolve_short_drama_narration_language()
drama_genre = _resolve_short_drama_type()
original_sound_ratio = int(st.session_state.get('short_drama_original_sound_ratio', 30))
if script_path in SUMMARY_SCRIPT_MODES and (narration_copy_clicked or action_clicked):
summary_config = _summary_mode_config(script_path)
type_option_key = _summary_state_key(summary_config, "type_option")
custom_type_key = _summary_state_key(summary_config, "custom_type")
original_sound_ratio_key = _summary_state_key(summary_config, "original_sound_ratio")
language_option_key = _summary_state_key(summary_config, "narration_language_option")
custom_language_key = _summary_state_key(summary_config, "custom_narration_language")
narration_copy_key = _summary_state_key(summary_config, "narration_copy")
plot_analysis_key = _summary_state_key(summary_config, "plot_analysis")
plot_source_key = _summary_state_key(summary_config, "plot_analysis_subtitle_path")
plot_signature_key = _summary_state_key(summary_config, "plot_analysis_signature")
web_search_key = _summary_state_key(summary_config, "web_search_enabled")
narration_language = _resolve_summary_narration_language(summary_config)
drama_genre = _resolve_summary_type(summary_config)
original_sound_ratio = int(st.session_state.get(original_sound_ratio_key, 30))
if (
st.session_state.get('short_drama_type_option') == "custom"
and not str(st.session_state.get('short_drama_custom_type', '') or '').strip()
st.session_state.get(type_option_key) == "custom"
and not str(st.session_state.get(custom_type_key, '') or '').strip()
):
st.error(tr("请输入自定义短剧类型"))
st.error(tr(summary_config["custom_type_empty_key"]))
st.stop()
if (
st.session_state.get('short_drama_narration_language_option') == "custom"
and not str(st.session_state.get('short_drama_custom_narration_language', '') or '').strip()
st.session_state.get(language_option_key) == "custom"
and not str(st.session_state.get(custom_language_key, '') or '').strip()
):
st.error(tr("请输入自定义解说语言"))
st.stop()
@ -1387,7 +1533,7 @@ def render_script_buttons(tr, params):
subtitle_path = subtitle_paths[0] if subtitle_paths else None
video_theme = st.session_state.get('video_theme')
temperature = st.session_state.get('temperature')
web_search_enabled = bool(st.session_state.get('short_drama_web_search_enabled', False))
web_search_enabled = bool(st.session_state.get(web_search_key, False))
current_signature = _short_drama_plot_analysis_signature(
subtitle_paths,
video_theme,
@ -1395,13 +1541,13 @@ def render_script_buttons(tr, params):
_selected_video_paths(),
)
plot_analysis = ""
if st.session_state.get('short_drama_plot_analysis_signature') == current_signature:
plot_analysis = st.session_state.get('short_drama_plot_analysis', '')
if st.session_state.get(plot_signature_key) == current_signature:
plot_analysis = st.session_state.get(plot_analysis_key, '')
elif (
not web_search_enabled
and st.session_state.get('short_drama_plot_analysis_subtitle_path') == subtitle_path
and st.session_state.get(plot_source_key) == subtitle_path
):
plot_analysis = st.session_state.get('short_drama_plot_analysis', '')
plot_analysis = st.session_state.get(plot_analysis_key, '')
if narration_copy_clicked:
with st.spinner(tr("Generating narration copy...")):
@ -1416,13 +1562,17 @@ def render_script_buttons(tr, params):
video_paths=_selected_video_paths(),
narration_language=narration_language,
drama_genre=drama_genre,
prompt_category=summary_config["prompt_category"],
search_keywords=summary_config["search_keywords"],
empty_title_message_key=summary_config["empty_title_message_key"],
web_search_context_description=summary_config["web_search_context_description"],
)
if copy_result:
st.session_state['short_drama_narration_copy'] = copy_result["narration_copy"]
st.session_state[narration_copy_key] = copy_result["narration_copy"]
if not plot_analysis:
st.session_state['short_drama_plot_analysis'] = copy_result["plot_analysis"]
st.session_state['short_drama_plot_analysis_subtitle_path'] = subtitle_path
st.session_state['short_drama_plot_analysis_signature'] = current_signature
st.session_state[plot_analysis_key] = copy_result["plot_analysis"]
st.session_state[plot_source_key] = subtitle_path
st.session_state[plot_signature_key] = current_signature
st.success(tr("Narration copy generated successfully"))
if action_clicked:
@ -1437,20 +1587,25 @@ def render_script_buttons(tr, params):
enable_web_search=web_search_enabled,
video_paths=_selected_video_paths(),
narration_language=narration_language,
narration_copy=st.session_state.get('short_drama_narration_copy', ''),
narration_copy=st.session_state.get(narration_copy_key, ''),
drama_genre=drama_genre,
original_sound_ratio=original_sound_ratio,
prompt_category=summary_config["prompt_category"],
search_keywords=summary_config["search_keywords"],
empty_title_message_key=summary_config["empty_title_message_key"],
web_search_context_description=summary_config["web_search_context_description"],
)
if script_path == "summary":
if script_path in SUMMARY_SCRIPT_MODES:
summary_config = _summary_mode_config(script_path)
st.text_area(
tr("短剧解说文案"),
key="short_drama_narration_copy",
tr(summary_config["narration_copy_label_key"]),
key=_summary_state_key(summary_config, "narration_copy"),
height=220,
help=tr("Narration Copy Help"),
)
if action_clicked and script_path != "summary":
if action_clicked and script_path not in SUMMARY_SCRIPT_MODES:
if script_path == "auto":
# 执行纪录片视频脚本生成(视频无字幕无配音)
generate_script_docu(params, tr)

View File

@ -251,6 +251,7 @@
"Batch Size": "Batch Size",
"Batch Size (More keyframes consume more tokens)": "Batch Size (smaller batches consume more tokens)",
"Short Drama Summary": "Short Drama Summary",
"Film TV Narration": "Film/TV Narration",
"Video Type": "Creation Type",
"Select/Upload Script": "Custom Script",
"原生Gemini模型连接成功": "Native Gemini model connection succeeded",
@ -266,6 +267,7 @@
"字幕文件内容似乎为空,请检查文件": "The subtitle file appears to be empty. Please check the file.",
"字幕上传成功": "Subtitle uploaded successfully",
"短剧名称": "Short Drama Name",
"影视名称": "Film/TV Title",
"解说语言": "Narration Language",
"自定义解说语言": "Custom Narration Language",
"例如:意大利语(意大利)": "For example: Italian (Italy)",
@ -282,9 +284,13 @@
"自定义": "Custom",
"短剧类型": "Short Drama Type",
"自定义短剧类型": "Custom Short Drama Type",
"影视类型": "Film/TV Type",
"自定义影视类型": "Custom Film/TV Type",
"原片占比": "Original Footage Ratio",
"例如:豪门虐恋": "For example: billionaire angst romance",
"例如:悬疑犯罪": "For example: suspense crime",
"请输入自定义短剧类型": "Please enter a custom short drama type",
"请输入自定义影视类型": "Please enter a custom film/TV type",
"逆袭/复仇": "Counterattack / Revenge",
"霸总/甜宠": "CEO Romance / Sweet Romance",
"家庭伦理": "Family Ethics",
@ -292,9 +298,16 @@
"悬疑/犯罪": "Suspense / Crime",
"都市情感": "Urban Romance",
"年代/乡村": "Period / Rural",
"剧情/情感": "Drama / Emotion",
"动作/冒险": "Action / Adventure",
"喜剧/轻松": "Comedy / Light",
"科幻/奇幻": "Sci-Fi / Fantasy",
"历史/战争": "History / War",
"恐怖/惊悚": "Horror / Thriller",
"生成解说文案": "Generate Narration Copy",
"生成剪辑脚本": "Generate Editing Script",
"短剧解说文案": "Short Drama Narration Copy",
"影视解说文案": "Film/TV Narration Copy",
"Narration Copy Help": "Generate the narration copy first, review or rewrite it here, then generate the editing script to match footage and timestamps.",
"Narration copy generated successfully": "Narration copy generated. Please review and edit it.",
"生成短剧解说脚本": "Generate Short Drama Narration Script",
@ -459,12 +472,13 @@
"Transcribed subtitles storage hint": "Previously transcribed subtitles are saved in {path}; drag a file from that folder to upload",
"Tavily Search Settings": "Tavily Web Search",
"Tavily API Key": "Tavily API Key",
"Tavily API Key Help": "Used for web search before short drama plot analysis. When Web Search is enabled, the app searches plot, character, and episode context by drama name, then combines it with subtitles.",
"Tavily API Key Help": "Used for web search before plot analysis. When Web Search is enabled, the app searches plot, character, and background context by title, then combines it with subtitles.",
"Tavily config saved": "Tavily configuration saved",
"联网搜索": "Web Search",
"Enable Web Search Help": "When enabled, plot analysis searches the web with Tavily by short drama name before combining those results with subtitles.",
"Enable Web Search Help": "When enabled, plot analysis searches the web with Tavily by title before combining those results with subtitles.",
"Please configure Tavily API Key in Basic Settings": "Please configure the Tavily API Key in Basic Settings first",
"Please enter short drama name before web search": "Please enter the short drama name before enabling web search",
"Please enter film/tv title before web search": "Please enter the film/TV title before enabling web search",
"Searching short drama with Tavily...": "Searching short drama context with Tavily...",
"Tavily search failed": "Tavily search failed",
"剧情理解": "Plot Analysis",

View File

@ -241,6 +241,7 @@
"Batch Size": "批处理大小",
"Batch Size (More keyframes consume more tokens)": "批处理大小, 每批处理越少消耗 token 越多",
"Short Drama Summary": "短剧解说",
"Film TV Narration": "影视解说",
"Video Type": "创作类型",
"Select/Upload Script": "自定义脚本",
"Script loaded successfully": "脚本加载成功",
@ -410,12 +411,13 @@
"Transcribed subtitles storage hint": "之前转录生成的字幕保存在 {path},可从该目录拖入上传",
"Tavily Search Settings": "Tavily 联网搜索",
"Tavily API Key": "Tavily API Key",
"Tavily API Key Help": "用于短剧剧情理解前的联网检索。开启“联网搜索”后,会先按短剧名称检索剧情、人物和分集信息,再结合字幕分析。",
"Tavily API Key Help": "用于剧情理解前的联网检索。开启“联网搜索”后,会先按作品名称检索剧情、人物和背景信息,再结合字幕分析。",
"Tavily config saved": "Tavily 配置已保存",
"联网搜索": "联网搜索",
"Enable Web Search Help": "开启后,剧情理解会先使用 Tavily 按短剧名称联网检索,再结合检索结果和字幕分析剧情。",
"Enable Web Search Help": "开启后,剧情理解会先使用 Tavily 按作品名称联网检索,再结合检索结果和字幕分析剧情。",
"Please configure Tavily API Key in Basic Settings": "请先在基础设置中配置 Tavily API Key",
"Please enter short drama name before web search": "开启联网搜索前,请先填写短剧名称",
"Please enter film/tv title before web search": "开启联网搜索前,请先填写影视名称",
"Searching short drama with Tavily...": "正在使用 Tavily 检索短剧信息...",
"Tavily search failed": "Tavily 检索失败",
"剧情理解": "剧情理解",
@ -568,6 +570,7 @@
"字幕文件内容似乎为空,请检查文件": "字幕文件内容似乎为空,请检查文件",
"字幕上传成功": "字幕上传成功",
"短剧名称": "短剧名称",
"影视名称": "影视名称",
"解说语言": "解说语言",
"自定义解说语言": "自定义解说语言",
"例如:意大利语(意大利)": "例如:意大利语(意大利)",
@ -584,9 +587,13 @@
"自定义": "自定义",
"短剧类型": "短剧类型",
"自定义短剧类型": "自定义短剧类型",
"影视类型": "影视类型",
"自定义影视类型": "自定义影视类型",
"原片占比": "原片占比",
"例如:豪门虐恋": "例如:豪门虐恋",
"例如:悬疑犯罪": "例如:悬疑犯罪",
"请输入自定义短剧类型": "请输入自定义短剧类型",
"请输入自定义影视类型": "请输入自定义影视类型",
"逆袭/复仇": "逆袭/复仇",
"霸总/甜宠": "霸总/甜宠",
"家庭伦理": "家庭伦理",
@ -594,9 +601,16 @@
"悬疑/犯罪": "悬疑/犯罪",
"都市情感": "都市情感",
"年代/乡村": "年代/乡村",
"剧情/情感": "剧情/情感",
"动作/冒险": "动作/冒险",
"喜剧/轻松": "喜剧/轻松",
"科幻/奇幻": "科幻/奇幻",
"历史/战争": "历史/战争",
"恐怖/惊悚": "恐怖/惊悚",
"生成解说文案": "生成解说文案",
"生成剪辑脚本": "生成剪辑脚本",
"短剧解说文案": "短剧解说文案",
"影视解说文案": "影视解说文案",
"Narration Copy Help": "先点击生成解说文案;审核、删改或重写这段文案后,再点击生成剪辑脚本匹配画面和时间戳。",
"Narration copy generated successfully": "解说文案已生成,可先审核修改",
"生成短剧解说脚本": "生成短剧解说脚本",

View File

@ -25,7 +25,7 @@ from app.services.subtitle_text import read_subtitle_text
from app.services.short_drama_narration_validation import (
normalize_script_video_sources,
)
from app.services.tavily_search import TavilySearchError, format_search_context, search_short_drama
from app.services.tavily_search import TavilySearchError, format_search_context, search_story_context
# 导入新的LLM服务模块 - 确保提供商被注册
import app.services.llm # 这会触发提供商注册
from app.services.llm.migration_adapter import SubtitleAnalyzerAdapter
@ -33,6 +33,10 @@ import re
PUBLIC_SCRIPT_FIELDS = ["_id", "video_id", "video_name", "timestamp", "picture", "narration", "OST"]
SHORT_DRAMA_PROMPT_CATEGORY = "short_drama_narration"
FILM_TV_PROMPT_CATEGORY = "film_tv_narration"
SHORT_DRAMA_SEARCH_KEYWORDS = "短剧 剧情 介绍 人物 结局"
FILM_TV_SEARCH_KEYWORDS = "影视 剧情 介绍 人物 结局 电影 电视剧"
def _normalize_paths(paths):
@ -197,10 +201,15 @@ def _get_tavily_api_key() -> str:
).strip()
def _build_tavily_context(short_name: str, tr=lambda key: key) -> str | None:
short_name = str(short_name or "").strip()
if not short_name:
st.error(tr("Please enter short drama name before web search"))
def _build_tavily_context(
title: str,
tr=lambda key: key,
search_keywords: str = SHORT_DRAMA_SEARCH_KEYWORDS,
empty_title_message_key: str = "Please enter short drama name before web search",
) -> str | None:
title = str(title or "").strip()
if not title:
st.error(tr(empty_title_message_key))
return None
api_key = _get_tavily_api_key()
@ -209,9 +218,11 @@ def _build_tavily_context(short_name: str, tr=lambda key: key) -> str | None:
return None
try:
search_data = search_short_drama(
short_name,
search_data = search_story_context(
title,
api_key,
search_keywords=search_keywords,
empty_name_message=tr(empty_title_message_key),
search_depth=config.app.get("tavily_search_depth", "basic"),
max_results=config.app.get("tavily_max_results", 5),
)
@ -231,17 +242,25 @@ def _build_plot_analysis_input(
short_name: str = "",
enable_web_search: bool = False,
tr=lambda key: key,
search_keywords: str = SHORT_DRAMA_SEARCH_KEYWORDS,
empty_title_message_key: str = "Please enter short drama name before web search",
web_search_context_description: str = "短剧名称、人物关系、剧情背景和公开剧情梗概",
) -> str | None:
subtitle_content = str(subtitle_content or "").strip()
if not enable_web_search:
return subtitle_content
tavily_context = _build_tavily_context(short_name, tr)
tavily_context = _build_tavily_context(
short_name,
tr,
search_keywords=search_keywords,
empty_title_message_key=empty_title_message_key,
)
if tavily_context is None:
return None
return f"""# 分析补充说明
请先参考 Tavily 联网检索结果理解短剧名称人物关系剧情背景和公开剧情梗概再结合原始字幕完成剧情理解
请先参考 Tavily 联网检索结果理解{web_search_context_description}再结合原始字幕完成剧情理解
如果联网检索结果与字幕内容冲突请以字幕内容为准时间戳必须只从字幕内容中提取
{tavily_context}
@ -258,6 +277,10 @@ def analyze_short_drama_plot(
short_name: str = "",
enable_web_search: bool = False,
video_paths=None,
prompt_category: str = SHORT_DRAMA_PROMPT_CATEGORY,
search_keywords: str = SHORT_DRAMA_SEARCH_KEYWORDS,
empty_title_message_key: str = "Please enter short drama name before web search",
web_search_context_description: str = "短剧名称、人物关系、剧情背景和公开剧情梗概",
):
"""仅执行短剧字幕剧情理解,返回可编辑的剧情分析文本。"""
subtitle_paths = _normalize_paths(subtitle_path)
@ -287,13 +310,22 @@ def analyze_short_drama_plot(
short_name=short_name,
enable_web_search=enable_web_search,
tr=tr,
search_keywords=search_keywords,
empty_title_message_key=empty_title_message_key,
web_search_context_description=web_search_context_description,
)
if plot_analysis_input is None:
return None
try:
logger.info("使用新的LLM服务架构进行字幕分析")
analyzer = SubtitleAnalyzerAdapter(text_api_key, text_model, text_base_url, text_provider)
analyzer = SubtitleAnalyzerAdapter(
text_api_key,
text_model,
text_base_url,
text_provider,
prompt_category=prompt_category,
)
analysis_result = analyzer.analyze_subtitle(plot_analysis_input)
except Exception as e:
logger.warning(f"使用新LLM服务失败回退到旧实现: {str(e)}")
@ -304,7 +336,8 @@ def analyze_short_drama_plot(
base_url=text_base_url,
save_result=True,
temperature=temperature,
provider=text_provider
provider=text_provider,
prompt_category=prompt_category,
)
if analysis_result["status"] != "success":
@ -326,6 +359,10 @@ def generate_short_drama_narration_copy(
video_paths=None,
narration_language: str = "简体中文(中国)",
drama_genre: str = "逆袭/复仇",
prompt_category: str = SHORT_DRAMA_PROMPT_CATEGORY,
search_keywords: str = SHORT_DRAMA_SEARCH_KEYWORDS,
empty_title_message_key: str = "Please enter short drama name before web search",
web_search_context_description: str = "短剧名称、人物关系、剧情背景和公开剧情梗概",
):
"""生成可由用户审核修改的短剧解说正文,不绑定时间戳。"""
subtitle_paths = _normalize_paths(subtitle_path)
@ -356,6 +393,10 @@ def generate_short_drama_narration_copy(
short_name=video_theme,
enable_web_search=enable_web_search,
video_paths=selected_video_paths,
prompt_category=prompt_category,
search_keywords=search_keywords,
empty_title_message_key=empty_title_message_key,
web_search_context_description=web_search_context_description,
)
if not analysis_text:
return None
@ -367,7 +408,13 @@ def generate_short_drama_narration_copy(
try:
logger.info("使用新的LLM服务架构生成可审核解说文案")
analyzer = SubtitleAnalyzerAdapter(text_api_key, text_model, text_base_url, text_provider)
analyzer = SubtitleAnalyzerAdapter(
text_api_key,
text_model,
text_base_url,
text_provider,
prompt_category=prompt_category,
)
narration_result = analyzer.generate_narration_copy(
short_name=video_theme,
plot_analysis=analysis_text,
@ -389,6 +436,7 @@ def generate_short_drama_narration_copy(
provider=text_provider,
narration_language=narration_language,
drama_genre=drama_genre,
prompt_category=prompt_category,
)
if narration_result.get("status") != "success":
@ -423,6 +471,10 @@ def generate_script_short_sunmmary(
narration_copy: str = "",
drama_genre: str = "逆袭/复仇",
original_sound_ratio: int = 30,
prompt_category: str = SHORT_DRAMA_PROMPT_CATEGORY,
search_keywords: str = SHORT_DRAMA_SEARCH_KEYWORDS,
empty_title_message_key: str = "Please enter short drama name before web search",
web_search_context_description: str = "短剧名称、人物关系、剧情背景和公开剧情梗概",
):
"""
生成 短剧解说 视频脚本
@ -536,7 +588,13 @@ def generate_script_short_sunmmary(
st.error(tr("Please generate and review narration copy first"))
return
analyzer = SubtitleAnalyzerAdapter(text_api_key, text_model, text_base_url, text_provider)
analyzer = SubtitleAnalyzerAdapter(
text_api_key,
text_model,
text_base_url,
text_provider,
prompt_category=prompt_category,
)
if plot_analysis and str(plot_analysis).strip():
logger.info("使用用户编辑后的剧情理解结果匹配剪辑脚本")
analysis_result = {
@ -552,6 +610,9 @@ def generate_script_short_sunmmary(
short_name=video_theme,
enable_web_search=True,
tr=tr,
search_keywords=search_keywords,
empty_title_message_key=empty_title_message_key,
web_search_context_description=web_search_context_description,
)
if plot_analysis_input is None:
return
@ -572,7 +633,8 @@ def generate_script_short_sunmmary(
base_url=text_base_url,
save_result=True,
temperature=temperature,
provider=text_provider
provider=text_provider,
prompt_category=prompt_category,
)
"""
3. 根据用户审核后的文案匹配画面与时间戳
@ -612,6 +674,7 @@ def generate_script_short_sunmmary(
narration_language=narration_language,
drama_genre=drama_genre,
original_sound_ratio=original_sound_ratio,
prompt_category=prompt_category,
)
if narration_result["status"] == "success":