From d10c2ff7c5aed36f30f6bd2f830221f856f8b839 Mon Sep 17 00:00:00 2001 From: viccy Date: Mon, 8 Jun 2026 00:30:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(prompts,=20webui,=20llm):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=BD=B1=E8=A7=86=E8=A7=A3=E8=AF=B4=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=8F=8A=E9=85=8D=E5=A5=97=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增影视解说专属提示词模块,覆盖剧情分析、文案生成、片段规划、脚本匹配与修复全流程 - 注册影视解说模块到全局提示词系统,更新初始化加载逻辑 - 重构Tavily搜索服务,拆分通用搜索函数适配短剧和影视两类作品 - 更新WebUI界面,新增影视解说配置项、多语言翻译与版本号展示 - 升级项目版本号从0.7.9到0.8.1 - 调整LLM服务与适配器逻辑,支持自定义prompt分类适配不同解说类型 - 完善相关工具类与单元测试,覆盖影视解说场景调用流程 --- app/services/SDE/short_drama_explanation.py | 24 +- app/services/llm/migration_adapter.py | 15 +- ...test_subtitle_adapter_pipeline_unittest.py | 26 ++ app/services/llm/unified_service.py | 5 +- app/services/prompts/__init__.py | 2 + .../prompts/film_tv_narration/__init__.py | 48 +++ .../film_tv_narration/narration_copy.py | 88 ++++ .../film_tv_narration/plot_analysis.py | 99 +++++ .../film_tv_narration/script_generation.py | 152 +++++++ .../film_tv_narration/script_matching.py | 131 ++++++ .../film_tv_narration/script_repair.py | 96 +++++ .../film_tv_narration/segment_planning.py | 103 +++++ app/services/tavily_search.py | 33 +- project_version | 2 +- webui.py | 7 +- webui/components/script_settings.py | 375 +++++++++++++----- webui/i18n/en.json | 18 +- webui/i18n/zh.json | 18 +- webui/tools/generate_short_summary.py | 91 ++++- 19 files changed, 1186 insertions(+), 147 deletions(-) create mode 100644 app/services/prompts/film_tv_narration/__init__.py create mode 100644 app/services/prompts/film_tv_narration/narration_copy.py create mode 100644 app/services/prompts/film_tv_narration/plot_analysis.py create mode 100644 app/services/prompts/film_tv_narration/script_generation.py create mode 100644 app/services/prompts/film_tv_narration/script_matching.py create mode 100644 app/services/prompts/film_tv_narration/script_repair.py create mode 100644 app/services/prompts/film_tv_narration/segment_planning.py diff --git a/app/services/SDE/short_drama_explanation.py b/app/services/SDE/short_drama_explanation.py index 6910324..5d85679 100644 --- a/app/services/SDE/short_drama_explanation.py +++ b/app/services/SDE/short_drama_explanation.py @@ -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( diff --git a/app/services/llm/migration_adapter.py b/app/services/llm/migration_adapter.py index 96b165f..aec7ab5 100644 --- a/app/services/llm/migration_adapter.py +++ b/app/services/llm/migration_adapter.py @@ -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 ) diff --git a/app/services/llm/test_subtitle_adapter_pipeline_unittest.py b/app/services/llm/test_subtitle_adapter_pipeline_unittest.py index 2245031..c9ed3f9 100644 --- a/app/services/llm/test_subtitle_adapter_pipeline_unittest.py +++ b/app/services/llm/test_subtitle_adapter_pipeline_unittest.py @@ -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", diff --git a/app/services/llm/unified_service.py b/app/services/llm/unified_service.py index 071e8da..70d9ae6 100644 --- a/app/services/llm/unified_service.py +++ b/app/services/llm/unified_service.py @@ -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() diff --git a/app/services/prompts/__init__.py b/app/services/prompts/__init__.py index 3338673..55674cc 100644 --- a/app/services/prompts/__init__.py +++ b/app/services/prompts/__init__.py @@ -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() diff --git a/app/services/prompts/film_tv_narration/__init__.py b/app/services/prompts/film_tv_narration/__init__.py new file mode 100644 index 0000000..e98bc60 --- /dev/null +++ b/app/services/prompts/film_tv_narration/__init__.py @@ -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", +] diff --git a/app/services/prompts/film_tv_narration/narration_copy.py b/app/services/prompts/film_tv_narration/narration_copy.py new file mode 100644 index 0000000..7c6182a --- /dev/null +++ b/app/services/prompts/film_tv_narration/narration_copy.py @@ -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_analysis} + + +## 原始字幕 + +${subtitle_content} + + +## 输出语言 + +${narration_language} + + +## 用户选择的影视类型 + +${drama_genre} + + +## 类型写作规则 +必须按用户选择的影视类型调整表达重点,不要自行改判类型: +- 剧情/情感:突出人物选择、关系裂痕、命运压力和情绪余波。 +- 悬疑/犯罪:突出线索、疑点、动机、误导和未揭开的真相。 +- 动作/冒险:突出目标、危险升级、身体对抗和关键抉择。 +- 喜剧/轻松:突出误会、反差、节奏包袱和人物可爱处。 +- 科幻/奇幻:突出设定规则、未知威胁、世界观反差和代价。 +- 历史/战争:突出时代处境、阵营选择、牺牲和局势变化。 +- 恐怖/惊悚:突出异常细节、压迫感、未知危险和心理悬念。 +- 自定义类型:严格服从用户填写的类型方向。 + +## 开头钩子公式 +开头必须使用“人物困境 + 反常信息 + 悬念问题”: +1. 先点出主角或关键人物正在面对什么压力。 +2. 再抛出一个违背常识、关系突变或危险升级的信息。 +3. 最后留下观众想继续看的问题:他为什么这样做、谁在撒谎、这场选择会把所有人推向哪里。 + +## 写作规则 +1. 必须使用 ${narration_language}。 +2. 严格基于剧情理解和字幕事实,不编造核心情节、身份、结局。 +3. 先写清楚人物动机和因果链,再写情绪金句;不要只堆形容词。 +4. 每句话只表达一个信息点,适合后续按句匹配画面。 +5. 句子尽量短,单句优先 15-35 字;信息复杂时拆成多句。 +6. 每 2-3 句要有明确承接,让观众知道为什么从上一幕来到下一幕。 +7. 总长度控制在 350-750 字;短素材取下限,长素材取上限。 +8. 不要使用编号、项目符号、章节标题或括号说明。 + +## 输出要求 +只输出解说正文。不要输出 JSON、时间戳、代码块或任何解释。""" diff --git a/app/services/prompts/film_tv_narration/plot_analysis.py b/app/services/prompts/film_tv_narration/plot_analysis.py new file mode 100644 index 0000000..e32faf2 --- /dev/null +++ b/app/services/prompts/film_tv_narration/plot_analysis.py @@ -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}""" diff --git a/app/services/prompts/film_tv_narration/script_generation.py b/app/services/prompts/film_tv_narration/script_generation.py new file mode 100644 index 0000000..c945334 --- /dev/null +++ b/app/services/prompts/film_tv_narration/script_generation.py @@ -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_analysis} + + +### 已规划片段(必须逐项照抄结构字段) + +${segment_plan} + + +### 原始字幕(含视频编号和精确时间戳) + +${subtitle_content} + + +### 解说台词语言 + +${narration_language} + + +### 用户选择的影视类型 + +${drama_genre} + + +字幕可能来自多个视频文件。每个字幕分段标题会以“视频 1: 文件名”“视频 2: 文件名”等形式标识来源。 +生成脚本时必须把每个片段绑定到对应视频来源,时间戳表示该视频文件内部的局部时间,不是把多个视频拼接后的全局时间。 +所有 OST=0 的 narration 字段必须使用上方指定的解说台词语言输出;不要因为原始字幕是其他语言就切回字幕原语言。 +OST=1 的原声片段 narration 字段必须继续使用“播放原片+序号”格式,不要翻译这个固定标记。 + +## 绝对绑定规则 +1. 输出 items 数量、顺序和 _id 必须与 segment_plan 完全一致。 +2. 每个 item 的 _id、video_id、video_name、timestamp、OST 必须逐字复制 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_role、intent、transition 字段,必须利用它们组织 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}》创作解说脚本:""" diff --git a/app/services/prompts/film_tv_narration/script_matching.py b/app/services/prompts/film_tv_narration/script_matching.py new file mode 100644 index 0000000..9577e49 --- /dev/null +++ b/app/services/prompts/film_tv_narration/script_matching.py @@ -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_analysis} + + +## 用户审核后的解说文案 + +${narration_copy} + + +## 原始字幕(含视频编号和局部时间戳) + +${subtitle_content} + + +## 输出语言 + +${narration_language} + + +## 用户选择的影视类型 + +${drama_genre} + + +## 用户选择的原片占比 + +${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:描述匹配画面中人物、动作、情绪、场景和关键道具。 +- narration:OST=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 + } + ] +} + +现在请基于用户审核后的解说文案生成最终剪辑脚本。""" diff --git a/app/services/prompts/film_tv_narration/script_repair.py b/app/services/prompts/film_tv_narration/script_repair.py new file mode 100644 index 0000000..cdd9c88 --- /dev/null +++ b/app/services/prompts/film_tv_narration/script_repair.py @@ -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_analysis} + + +## 校验错误 + +${validation_errors} + + +## 当前无效脚本 + +${invalid_script} + + +## 可用字幕窗口 + +${subtitle_content} + + +## 解说台词目标语言 + +${narration_language} + + +## 用户选择的影视类型 + +${drama_genre} + + +## 修复规则 +1. 只输出 JSON,不要任何解释、标题、Markdown 或代码块。 +2. 输出根对象必须是 {"items": [...]}。 +3. 每个 item 必须包含 _id、video_id、video_name、timestamp、picture、narration、OST。 +4. video_id、video_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。""" diff --git a/app/services/prompts/film_tv_narration/segment_planning.py b/app/services/prompts/film_tv_narration/segment_planning.py new file mode 100644 index 0000000..a1da09e --- /dev/null +++ b/app/services/prompts/film_tv_narration/segment_planning.py @@ -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_analysis} + + +## 原始字幕(含视频编号和局部时间戳) + +${subtitle_content} + + +## 解说台词目标语言 + +${narration_language} + + +## 用户选择的影视类型 + +${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}》的解说片段。""" diff --git a/app/services/tavily_search.py b/app/services/tavily_search.py index 586a7ee..0f61014 100644 --- a/app/services/tavily_search.py +++ b/app/services/tavily_search.py @@ -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: diff --git a/project_version b/project_version index 1451d48..c18d72b 100644 --- a/project_version +++ b/project_version @@ -1 +1 @@ -0.7.9 \ No newline at end of file +0.8.1 \ No newline at end of file diff --git a/webui.py b/webui.py index 34d1204..57f8eb7 100644 --- a/webui.py +++ b/webui.py @@ -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部分 # 渲染基础设置面板 diff --git a/webui/components/script_settings.py b/webui/components/script_settings.py index 555e1e1..e57c42d 100644 --- a/webui/components/script_settings.py +++ b/webui/components/script_settings.py @@ -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""" """, 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) diff --git a/webui/i18n/en.json b/webui/i18n/en.json index 0a8fb4b..24b2f0a 100644 --- a/webui/i18n/en.json +++ b/webui/i18n/en.json @@ -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", diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json index 1099604..539a6d1 100644 --- a/webui/i18n/zh.json +++ b/webui/i18n/zh.json @@ -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": "解说文案已生成,可先审核修改", "生成短剧解说脚本": "生成短剧解说脚本", diff --git a/webui/tools/generate_short_summary.py b/webui/tools/generate_short_summary.py index ab1e71b..468206d 100644 --- a/webui/tools/generate_short_summary.py +++ b/webui/tools/generate_short_summary.py @@ -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":