mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-07-24 23:18:13 +00:00
perf(clip_video): 优化FFmpeg剪辑命令为快速搜索模式,添加单元测试
优化了视频剪辑的FFmpeg命令参数顺序,将原本后置`-ss`的慢搜索改为前置`-ss`的快速搜索模式,大幅减少长视频剪辑时的不必要解码开销。重构了时间处理逻辑,新增辅助函数统一转换时间格式与计算裁剪时长,更新了所有兼容降级的编码命令以适配新参数格式,同时新增单元测试验证命令参数的正确性。
This commit is contained in:
parent
d10c2ff7c5
commit
8e4271c2ce
@ -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"]])
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 要描述画面和人物状态,便于后期识别素材。
|
||||
|
||||
@ -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,全部使用解说承接。
|
||||
|
||||
@ -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。"""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user