diff --git a/app/services/clip_video.py b/app/services/clip_video.py index 93f9ddd..a8235f3 100644 --- a/app/services/clip_video.py +++ b/app/services/clip_video.py @@ -32,6 +32,32 @@ def parse_timestamp(timestamp: str) -> tuple: return start_time, end_time +def _ffmpeg_time_to_seconds(time_value: str) -> float: + normalized_time = str(time_value).strip().replace(",", ".") + parts = normalized_time.split(":") + + if len(parts) == 3: + hours, minutes, seconds = parts + return int(hours) * 3600 + int(minutes) * 60 + float(seconds) + if len(parts) == 2: + minutes, seconds = parts + return int(minutes) * 60 + float(seconds) + return float(normalized_time) + + +def _calculate_ffmpeg_duration(start_time: str, end_time: str) -> str: + duration = _ffmpeg_time_to_seconds(end_time) - _ffmpeg_time_to_seconds(start_time) + if duration <= 0: + raise ValueError(f"无效的视频裁剪时间范围: {start_time} -> {end_time}") + + return f"{duration:.3f}".rstrip("0").rstrip(".") + + +def _append_fast_seek_input(cmd: List[str], input_path: str, start_time: str, end_time: str) -> None: + duration = _calculate_ffmpeg_duration(start_time, end_time) + cmd.extend(["-ss", start_time, "-i", input_path, "-t", duration]) + + def _normalize_video_origin_paths( video_origin_path: str, video_origin_paths: Optional[List[str]] = None, @@ -253,11 +279,8 @@ def build_ffmpeg_command( # 对于其他编码器,可以使用硬件解码参数 cmd.extend(hwaccel_args) - # 输入文件 - cmd.extend(["-i", input_path]) - - # 时间范围 - cmd.extend(["-ss", start_time, "-to", end_time]) + # 快速定位输入文件,避免长视频从头解码到目标片段 + _append_fast_seek_input(cmd, input_path, start_time, end_time) # 编码器设置 cmd.extend(["-c:v", encoder_config["video_codec"]]) @@ -439,11 +462,12 @@ def try_compatibility_fallback( bool: 是否成功 """ # 兼容性模式:避免所有可能的滤镜链问题 + duration = _calculate_ffmpeg_duration(start_time, end_time) fallback_cmd = [ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", - "-i", input_path, "-ss", start_time, - "-to", end_time, + "-i", input_path, + "-t", duration, "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", # 明确指定像素格式 @@ -480,11 +504,12 @@ def try_software_fallback( bool: 是否成功 """ # 纯软件编码 + duration = _calculate_ffmpeg_duration(start_time, end_time) fallback_cmd = [ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", - "-i", input_path, "-ss", start_time, - "-to", end_time, + "-i", input_path, + "-t", duration, "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", @@ -520,11 +545,12 @@ def try_basic_fallback( bool: 是否成功 """ # 最基本的编码参数 + duration = _calculate_ffmpeg_duration(start_time, end_time) fallback_cmd = [ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", - "-i", input_path, "-ss", start_time, - "-to", end_time, + "-i", input_path, + "-t", duration, "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", @@ -603,11 +629,12 @@ def try_fallback_encoding( bool: 是否成功 """ # 最简单的软件编码命令 + duration = _calculate_ffmpeg_duration(start_time, end_time) fallback_cmd = [ "ffmpeg", "-y", - "-i", input_path, "-ss", start_time, - "-to", end_time, + "-i", input_path, + "-t", duration, "-c:v", "libx264", "-c:a", "aac", "-pix_fmt", "yuv420p", @@ -801,11 +828,8 @@ def _build_ffmpeg_command_with_audio_control( elif hwaccel_args: cmd.extend(hwaccel_args) - # 输入文件 - cmd.extend(["-i", input_path]) - - # 时间范围 - cmd.extend(["-ss", start_time, "-to", end_time]) + # 快速定位输入文件,避免长视频从头解码到目标片段 + _append_fast_seek_input(cmd, input_path, start_time, end_time) # 视频编码器设置 cmd.extend(["-c:v", encoder_config["video_codec"]]) diff --git a/app/services/llm/test_subtitle_adapter_pipeline_unittest.py b/app/services/llm/test_subtitle_adapter_pipeline_unittest.py index c9ed3f9..77087e6 100644 --- a/app/services/llm/test_subtitle_adapter_pipeline_unittest.py +++ b/app/services/llm/test_subtitle_adapter_pipeline_unittest.py @@ -56,6 +56,44 @@ class SubtitleAnalyzerAdapterPipelineTests(unittest.TestCase): self.assertIn("用户选择的影视类型", call.call_args.kwargs["prompt"]) self.assertNotIn("短剧解说正文创作任务", call.call_args.kwargs["prompt"]) + def test_film_tv_script_prompts_exclude_intro_outro_and_ads(self): + base_parameters = { + "drama_name": "测试电影", + "drama_genre": "悬疑/犯罪", + "plot_analysis": "主角发现证据疑点。", + "subtitle_content": "# 视频 1: 1.mp4\n00:00:01,000 --> 00:00:04,000\n证据不对。", + "narration_language": "简体中文(中国)", + } + prompt_parameters = { + "segment_planning": base_parameters, + "script_matching": { + **base_parameters, + "narration_copy": "他发现证据不对,真正的凶手另有其人。", + "original_sound_ratio": 30, + }, + "script_generation": { + **base_parameters, + "segment_plan": '{"segments": []}', + }, + "script_repair": { + **base_parameters, + "invalid_script": '{"items": []}', + "validation_errors": "片段包含广告", + }, + } + + for prompt_name, parameters in prompt_parameters.items(): + with self.subTest(prompt_name=prompt_name): + prompt = PromptManager.get_prompt( + category="film_tv_narration", + name=prompt_name, + parameters=parameters, + ) + self.assertIn("片头", prompt) + self.assertIn("片尾", prompt) + self.assertIn("广告", prompt) + self.assertIn("绝对不能", 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/prompts/film_tv_narration/script_generation.py b/app/services/prompts/film_tv_narration/script_generation.py index c945334..e1ca2d1 100644 --- a/app/services/prompts/film_tv_narration/script_generation.py +++ b/app/services/prompts/film_tv_narration/script_generation.py @@ -77,8 +77,9 @@ ${drama_genre} OST=1 的原声片段 narration 字段必须继续使用“播放原片+序号”格式,不要翻译这个固定标记。 ## 绝对绑定规则 -1. 输出 items 数量、顺序和 _id 必须与 segment_plan 完全一致。 -2. 每个 item 的 _id、video_id、video_name、timestamp、OST 必须逐字复制 segment_plan,不得新增、删除、合并、拆分或改动。 +0. 最高优先级:如果 segment_plan 中混入片头、片尾、演职员表、版权声明、平台水印展示、下集预告、花絮、赞助口播、商品露出、贴片广告、中插广告、片中广告或任何与主线剧情无关的推广片段,必须直接删除这些片段,绝对不能输出到最终 items;此规则高于下面所有“照抄 segment_plan”的绑定规则。 +1. 除被第 0 条删除的片头、片尾和广告片段外,输出 items 数量、顺序和 _id 必须与 segment_plan 完全一致。 +2. 除被第 0 条删除的片头、片尾和广告片段外,每个 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},并严格基于剧情和字幕,不虚构字幕外的具体事件。 @@ -116,6 +117,7 @@ OST=1 的原声片段 narration 字段必须继续使用“播放原片+序号 ## 文案质量要求 - 开场片段要有强钩子,直接点出冲突、疑点或人物困境。 +- 最终剪辑脚本不得包含片头、片尾或任何广告片段;如果字幕内容明显属于非剧情推广,不要把它包装成剧情解说。 - 每段解说优先 25-90 字,具体长度必须服从画面时长;短画面宁可少说,不要密集灌信息。 - 可以使用“可真正的问题是”“而他还不知道”“这句话背后”“危险已经开始靠近”等影视解说转折语,但不要堆砌。 - picture 要描述画面和人物状态,便于后期识别素材。 diff --git a/app/services/prompts/film_tv_narration/script_matching.py b/app/services/prompts/film_tv_narration/script_matching.py index 9577e49..9dfa528 100644 --- a/app/services/prompts/film_tv_narration/script_matching.py +++ b/app/services/prompts/film_tv_narration/script_matching.py @@ -85,14 +85,16 @@ ${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} 叙事重点。 +4. 严禁把解说文案匹配到片头、片尾、演职员表、版权声明、平台水印展示、下集预告、花絮、赞助口播、商品露出、贴片广告、中插广告、片中广告或任何与主线剧情无关的推广片段;这些内容绝对不能进入最终 items。 +5. 如果字幕或画面文字出现“广告”“赞助”“推广”“片头”“片尾”“预告”“下集”“扫码”“购买”“会员”“关注”等明显非剧情信号,必须跳过对应时间段,不得用作 OST=0 或 OST=1。 +6. 为每个解说片段寻找最匹配的原始字幕画面,优先选择能表达该句核心含义、人物状态或信息转折的画面。 +7. 使用公式估算所需画面时长:所需秒数 = 解说字数 / 5。匹配画面时长尽量接近,误差优先控制在 ±0.5 秒。 +8. 如果一句解说太长,必须拆成多个 OST=0 片段,分别匹配不同或连续画面。 +9. timestamp 必须使用对应 video_id 内部局部时间戳,不得换算为多个视频拼接后的累计时间。 +10. 同一 video_id 内时间段不得交叉或重叠。 +11. 第一段必须是 OST=0 解说钩子,不能直接播放原片。 +12. OST=1 原声片段的总时长占比要尽量接近用户选择的 ${original_sound_ratio}%。这里按最终 items 的 timestamp 总时长估算,不按片段数量估算。 +13. 不要自行判断或改写影视类型;画面匹配和 picture 描述要服务用户选择的 ${drama_genre} 叙事重点。 ## 原片占比规则 - ${original_sound_ratio}% = 0% 时,不要输出 OST=1,全部使用解说承接。 diff --git a/app/services/prompts/film_tv_narration/script_repair.py b/app/services/prompts/film_tv_narration/script_repair.py index cdd9c88..ad7e3f5 100644 --- a/app/services/prompts/film_tv_narration/script_repair.py +++ b/app/services/prompts/film_tv_narration/script_repair.py @@ -82,15 +82,17 @@ ${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 开始编号。 +4. 必须删除片头、片尾、演职员表、版权声明、平台水印展示、下集预告、花絮、赞助口播、商品露出、贴片广告、中插广告、片中广告或任何与主线剧情无关的推广片段;这些内容绝对不能出现在修复后的 items 中。 +5. 如果字幕或画面文字出现“广告”“赞助”“推广”“片头”“片尾”“预告”“下集”“扫码”“购买”“会员”“关注”等明显非剧情信号,必须删除对应 item,不得改写成解说片段。 +6. video_id、video_name 和 timestamp 必须来自对应字幕窗口;不得把不同视频的同名时间戳混用。 +7. 同一 video_id 内片段不得交叉或重叠。 +8. OST=1 的 narration 必须是“播放原片+序号”;OST=0 的 narration 必须使用 ${narration_language}。 +9. 禁止连续 3 个或更多 OST=1;必须插入或改写 OST=0 解说片段承接剧情。 +10. 跨 video_id 切换前后不能都是 OST=1;必须至少有一个 OST=0 片段解释场景和剧情为什么切换。 +11. OST=0 narration 要补足人物动机、信息承接和因果转折,不要只概括当前画面。 +12. 第一段必须是 OST=0 解说钩子,按“人物困境 + 反常信息 + 悬念问题”写,不要直接播放原片。 +13. OST=0 文案必须匹配画面时长,按“解说字数 / 5 = 所需视频秒数”估算;过密时要缩短文案、延长时间戳或拆成多个片段。 +14. 不要自行改判影视类型;如需改写 narration,必须按用户选择的 ${drama_genre} 保持表达重点。 +15. 尽量保留原脚本中没有错误的片段;无法修复的片段可以删除,但剩余片段必须重新按 1 开始编号。 请输出修复后的完整 JSON。""" diff --git a/app/services/prompts/film_tv_narration/segment_planning.py b/app/services/prompts/film_tv_narration/segment_planning.py index a1da09e..f598b9c 100644 --- a/app/services/prompts/film_tv_narration/segment_planning.py +++ b/app/services/prompts/film_tv_narration/segment_planning.py @@ -71,16 +71,18 @@ ${drama_genre} 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 = 所需视频秒数”预估,短画面不要承载长解说。 +4. 严禁选择片头、片尾、演职员表、版权声明、平台水印展示、下集预告、花絮、赞助口播、商品露出、贴片广告、中插广告、片中广告或任何与主线剧情无关的推广片段;这些内容绝对不能进入 segments。 +5. 如果字幕或画面文字出现“广告”“赞助”“推广”“片头”“片尾”“预告”“下集”“扫码”“购买”“会员”“关注”等明显非剧情信号,必须整段跳过,不得用作 OST=0 或 OST=1。 +6. 每个片段必须推动主线、解释人物动机、制造情绪转折、承接原声或保留关键对白。 +7. OST=1 表示保留原声,适合关键对白、情绪爆发、真相揭露、名场面和反转;OST=0 表示后续需要配解说。 +8. 原声片段单段优先控制在 3-10 秒;解说片段可以更长,但必须能从字幕范围中定位。 +9. 影视类型由用户手动选择为 ${drama_genre},不得自行改判;选片段时优先服务该类型的主要看点。 +10. 禁止连续 3 个或更多 OST=1;每 1-2 个原声片段后必须安排 OST=0 解说片段承接剧情。 +11. 跨 video_id 切换前后必须至少有一个 OST=0 片段作为剧情桥段,解释为什么从上一场转到下一场。 +12. 每个 OST=0 片段必须承担明确叙事功能:开场钩子、人物介绍、因果过渡、信息解释、情绪转折、冲突升级、结尾悬念。 +13. 不要跳过关键因果;关系变化、线索发现、危机升级必须有画面或解说桥段承接。 +14. 结尾优先选择能留下新问题、新危险或人物选择的片段,不要只停在原声对白堆叠上。 +15. 解说画面必须给足时长:按“解说字数 / 5 = 所需视频秒数”预估,短画面不要承载长解说。 ## 输出格式 只输出严格 JSON: diff --git a/app/services/test_multi_video_script_sources_unittest.py b/app/services/test_multi_video_script_sources_unittest.py index dd6fce8..f4b7c0f 100644 --- a/app/services/test_multi_video_script_sources_unittest.py +++ b/app/services/test_multi_video_script_sources_unittest.py @@ -9,6 +9,23 @@ from app.utils import check_script class TestMultiVideoScriptSources(unittest.TestCase): + def test_clip_command_uses_input_fast_seek(self): + encoder_config = clip_video.get_safe_encoder_config(None) + + cmd = clip_video._build_ffmpeg_command_with_audio_control( + input_path="/tmp/input.mp4", + output_path="/tmp/output.mp4", + start_time="00:27:32.240", + end_time="00:27:38.240", + encoder_config=encoder_config, + hwaccel_args=[], + remove_audio=False, + ) + + self.assertLess(cmd.index("-ss"), cmd.index("-i")) + self.assertEqual("6", cmd[cmd.index("-t") + 1]) + self.assertNotIn("-to", cmd) + def test_check_format_accepts_optional_video_source_fields(self): script = [ {