perf(clip_video): 优化FFmpeg剪辑命令为快速搜索模式,添加单元测试

优化了视频剪辑的FFmpeg命令参数顺序,将原本后置`-ss`的慢搜索改为前置`-ss`的快速搜索模式,大幅减少长视频剪辑时的不必要解码开销。重构了时间处理逻辑,新增辅助函数统一转换时间格式与计算裁剪时长,更新了所有兼容降级的编码命令以适配新参数格式,同时新增单元测试验证命令参数的正确性。
This commit is contained in:
viccy 2026-06-08 01:58:40 +08:00
parent d10c2ff7c5
commit 8e4271c2ce
7 changed files with 135 additions and 48 deletions

View File

@ -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"]])

View File

@ -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",

View File

@ -77,8 +77,9 @@ ${drama_genre}
OST=1 的原声片段 narration 字段必须继续使用播放原片+序号格式不要翻译这个固定标记
## 绝对绑定规则
1. 输出 items 数量顺序和 _id 必须与 segment_plan 完全一致
2. 每个 item _idvideo_idvideo_nametimestampOST 必须逐字复制 segment_plan不得新增删除合并拆分或改动
0. 最高优先级如果 segment_plan 中混入片头片尾演职员表版权声明平台水印展示下集预告花絮赞助口播商品露出贴片广告中插广告片中广告或任何与主线剧情无关的推广片段必须直接删除这些片段绝对不能输出到最终 items此规则高于下面所有照抄 segment_plan的绑定规则
1. 除被第 0 条删除的片头片尾和广告片段外输出 items 数量顺序和 _id 必须与 segment_plan 完全一致
2. 除被第 0 条删除的片头片尾和广告片段外每个 item _idvideo_idvideo_nametimestampOST 必须逐字复制 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 要描述画面和人物状态便于后期识别素材

View File

@ -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全部使用解说承接

View File

@ -82,15 +82,17 @@ ${drama_genre}
1. 只输出 JSON不要任何解释标题Markdown 或代码块
2. 输出根对象必须是 {"items": [...]}
3. 每个 item 必须包含 _idvideo_idvideo_nametimestamppicturenarrationOST
4. video_idvideo_name timestamp 必须来自对应字幕窗口不得把不同视频的同名时间戳混用
5. 同一 video_id 内片段不得交叉或重叠
6. OST=1 narration 必须是播放原片+序号OST=0 narration 必须使用 ${narration_language}
7. 禁止连续 3 个或更多 OST=1必须插入或改写 OST=0 解说片段承接剧情
8. video_id 切换前后不能都是 OST=1必须至少有一个 OST=0 片段解释场景和剧情为什么切换
9. OST=0 narration 要补足人物动机信息承接和因果转折不要只概括当前画面
10. 第一段必须是 OST=0 解说钩子人物困境 + 反常信息 + 悬念问题不要直接播放原片
11. OST=0 文案必须匹配画面时长解说字数 / 5 = 所需视频秒数估算过密时要缩短文案延长时间戳或拆成多个片段
12. 不要自行改判影视类型如需改写 narration必须按用户选择的 ${drama_genre} 保持表达重点
13. 尽量保留原脚本中没有错误的片段无法修复的片段可以删除但剩余片段必须重新按 1 开始编号
4. 必须删除片头片尾演职员表版权声明平台水印展示下集预告花絮赞助口播商品露出贴片广告中插广告片中广告或任何与主线剧情无关的推广片段这些内容绝对不能出现在修复后的 items
5. 如果字幕或画面文字出现广告赞助推广片头片尾预告下集扫码购买会员关注等明显非剧情信号必须删除对应 item不得改写成解说片段
6. video_idvideo_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"""

View File

@ -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

View File

@ -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 = [
{