SRT 视频剪辑 Skill(SRT + LCS 匹配版)
概述
核心思路:用户已有 SRT 字幕文件(与视频同目录、同前缀名),直接解析 SRT 获取时间戳,用 LCS 算法将口播稿每个段落匹配到 SRT 字幕条目,生成剪映草稿。
严禁修改或删除任何源素材文件(视频、SRT、JSON 等),所有操作只读取、只生成新文件。
整体流程:
1. 接收用户输入:口播稿 + 视频目录路径
2. 自动发现目录下的 SRT 文件 → 映射到同名视频文件
3. 解析口播稿为段落列表(以空行分段)
4. 解析所有 SRT 文件,按视频分组
5. LCS 匹配:每个段落对每个视频的字幕条目做单条目 + 多条目合并窗口匹配
6. 输出匹配报告 → 用户确认
7. 生成 generate_draft.py → 运行 → 剪映草稿
Step 1:接收输入并发现 SRT 文件
用户输入格式
用户需要提供两样东西:
- 口播稿:纯文本,段落之间以空行(
\n\n)分隔。例如:
```
改为早上。我有一个好玩的事儿,特别好玩儿。
特别的毒的眼泪,就是毒誓,你能感到打车的时候,
```
- 视频目录路径:包含
.MP4/.mp4视频文件和对应的_原文.srt字幕文件。命名规则:
- 视频:20260515_js-qj-1-声好.MP4
- 字幕:20260515_js-qj-1-声好_原文.srt
自动发现 SRT → 视频映射
import os, glob
def discover_srt_video_pairs(video_dir: str) -> dict:
"""
扫描目录,找出所有 SRT 文件并映射到同名视频。
返回: {video_basename: {"srt": full_srt_path, "video": full_video_path}}
"""
pairs = {}
# 查找所有 SRT 文件
srt_files = glob.glob(os.path.join(video_dir, "*_原文.srt"))
for srt_path in srt_files:
srt_basename = os.path.basename(srt_path)
# 去掉 "_原文.srt" 后缀,得到视频基础名
video_basename = srt_basename.replace("_原文.srt", "")
# 尝试多种扩展名匹配视频
for ext in [".MP4", ".mp4", ".MOV", ".mov", ".mkv", ".avi"]:
video_path = os.path.join(video_dir, video_basename + ext)
if os.path.exists(video_path):
pairs[video_basename] = {"srt": srt_path, "video": video_path}
break
return pairs
如果不满足上述命名规则,直接询问用户具体的 SRT 文件名和对应视频文件名。
Step 2:解析口播稿为段落
def parse_script(script_text: str) -> list[str]:
"""
以空行分段。每段去除首尾空白。
返回原始文本段落(不去标点,保留原文以用于匹配显示)。
"""
paragraphs = []
for block in script_text.strip().split("\n\n"):
block = block.strip()
if block:
paragraphs.append(block)
return paragraphs
用于匹配的"干净文本"(去标点、去空格)仅在 LCS 计算时使用,原始文本保留用于显示和注释。
Step 3:解析 SRT 字幕文件
import re
def parse_srt(filepath: str, video_name: str) -> list[tuple]:
"""
解析单个 SRT 文件。
返回: [(start_seconds, end_seconds, text, video_name), ...]
"""
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
entries = []
blocks = re.split(r"\n\s*\n", content.strip())
for block in blocks:
lines = block.strip().split("\n")
if len(lines) < 3:
continue
# 解析时间戳行:00:01:58,260 --> 00:02:04,653
tm = re.match(
r"(\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3})",
lines[1]
)
if not tm:
continue
g = list(map(int, tm.groups()))
start = g[0] * 3600 + g[1] * 60 + g[2] + g[3] / 1000
end = g[4] * 3600 + g[5] * 60 + g[6] + g[7] / 1000
# 合并字幕文本行
text = "".join(lines[2:]).replace("\n", "")
entries.append((start, end, text, video_name))
return entries
Step 4:SequenceMatcher 全文匹配(核心算法)
> 性能对比:暴力 LCS 窗口 ~3 分钟/6 段 → SequenceMatcher <5 秒/8 段。
> 原理:difflib.SequenceMatcher 是 C 实现,将每视频所有 SRT 结巴拼接为全文后一次匹配找到最佳块,避免 O(nmwin) 的 brute-force。
建立字符→时间索引
from difflib import SequenceMatcher
def parse_srt_indexed(filepath: str, video_name: str) -> dict:
"""
解析 SRT 并建立字符到时间的映射。
返回: {
"full_text": 拼接后的无标点全文,
"char_times": [(time, entry_idx), ...] 每个字符位置 -> 时间戳,
"entries": [(start, end, raw_text), ...]
}
"""
# ... 先按 Step 3 的 parse_srt 得到 entries ...
chars = []
char_times = []
for idx, (start, end, text) in enumerate(entries):
cl = clean_text(text)
for ch in cl:
chars.append(ch)
char_times.append((start, idx))
if cl:
char_times[-1] = (end, idx) # 最后一个字符用 end 时间
return {
"full_text": "".join(chars),
"char_times": char_times,
"entries": entries,
}
匹配段落
def match_paragraph(para_clean: str, video_data: dict) -> dict:
"""
对所有视频做 SequenceMatcher 匹配,取最佳块。
video_data: {video_name: {"full_text": str, "char_times": list, "entries": list}}
"""
best = {"video": None, "start": 0.0, "end": 0.0, "score": 0.0}
for vname, data in video_data.items():
ft = data["full_text"]
ct = data["char_times"]
sm = SequenceMatcher(None, ft, para_clean)
for block in sm.get_matching_blocks():
if block.size == 0:
continue
b_start = block.a
b_size = block.size
if b_start >= len(ct) or b_start + b_size > len(ct):
continue
t_start = ct[b_start][0]
t_end = ct[min(b_start + b_size - 1, len(ct) - 1)][0]
score = b_size / max(len(para_clean), 1)
if score > best["score"]:
best = {"video": vname, "start": t_start, "end": t_end, "score": score}
return best
匹配阈值与处理
| 相似度 | 标记 | 处理方式 |
|---|---|---|
| ≥ 0.6 | ✅ | 直接接受,时间戳精确 |
| 0.3 ~ 0.6 | ⚠️ 低 | 接受但标注低置信度,提示用户确认 |
| < 0.3 | ❌ | 未匹配,在 CLIPS 中注释跳过,提示用户手动补充 |
核心原则:口播稿必须完整匹配。低置信度段落仍然放入 CLIPS,但标注警告让用户审查。绝不能静默丢弃任何段落。
Step 5:输出匹配报告
在执行草稿生成前,先输出清晰的匹配报告供用户审查:
================================================================================
匹配结果
================================================================================
段落1 ✅ → 20260515_js-qj-3-声好.MP4 [00:00:51~00:00:58]
相似度: 0.86 方式: 合并窗口 3条 (#17-#19)
口播: 改为早上。我有一个好玩的事儿,特别好玩儿。
段落2 ✅ → 20260515_js-qj-3-声好.MP4 [00:01:58~00:02:05]
相似度: 0.87 方式: 合并窗口 3条 (#51-#53)
口播: 特别的毒的眼泪,就是毒誓,你能感到打车的时候,
...
段落4 ⚠️ 低 → 20260515_js-qj-3-声好.MP4 [00:01:53~00:02:37]
相似度: 0.47 方式: 合并窗口 8条 (#49-#67)
口播: 一种方式就是晚上翻来覆去的...
⚠️ 低置信度 — 口播稿可能为浓缩版,实际口播内容跨度更长
================================================================================
CLIPS 列表
================================================================================
(r"Z:\...\20260515_js-qj-3-声好.MP4", 51.590, 58.225), # P1 sim:0.86
...
(⚠️ r"Z:\...\20260515_js-qj-3-声好.MP4", 112.700, 157.380), # P4 sim:0.47
Step 6:生成 pyJianYingDraft 脚本
基于匹配结果生成可直接运行的 Python 脚本。
关键点
- 直接用真实视频路径创建
VideoMaterial,不要用假视频骨架。 - 网络路径(如
\\192.168.x.x\...)可以直接使用,pyjianyingdraft 支持。 - 素材缓存:同一视频只创建一次
VideoMaterial。 - 时间轴累加:每个 segment 的 target_timerange 接在上一个之后。
allow_replace=True:每次覆盖同名草稿。
import os
import pyJianYingDraft as draft
from pyJianYingDraft import Timerange, SEC
# ============================================================
# 配置(根据实际路径修改)
# ============================================================
DRAFT_FOLDER = r"C:\Users\Administrator\AppData\Local\JianyingPro\User Data\Projects\com.lveditor.draft"
DRAFT_NAME = "你的草稿名称"
WIDTH, HEIGHT = 1920, 1080
# ============================================================
# CLIPS:口播稿段落 → (视频完整路径, 开始秒, 结束秒)
# ============================================================
CLIPS = [
# ⚠️ 由匹配结果填充,格式:(视频路径, start_sec, end_sec)
]
# ============================================================
# 生成草稿
# ============================================================
draft_folder = draft.DraftFolder(DRAFT_FOLDER)
script = draft_folder.create_draft(DRAFT_NAME, WIDTH, HEIGHT, allow_replace=True)
script.add_track(draft.TrackType.video)
timeline_cursor = 0
material_cache = {}
for video_path, src_start, src_end in CLIPS:
# 获取或创建素材
if video_path not in material_cache:
print(f"加载素材: {os.path.basename(video_path)}")
material_cache[video_path] = draft.VideoMaterial(video_path)
material = material_cache[video_path]
src_start_us = int(src_start * SEC)
src_end_us = int(src_end * SEC)
duration_us = src_end_us - src_start_us
segment = draft.VideoSegment(
material,
target_timerange=Timerange(timeline_cursor, duration_us),
source_timerange=Timerange(src_start_us, duration_us),
)
script.add_segment(segment)
print(f" 片段 [{src_start:.2f}s → {src_end:.2f}s] 时长 {duration_us/SEC:.2f}s")
timeline_cursor += duration_us
script.save()
print(f"\n✅ 草稿已保存:{DRAFT_NAME}")
print(f"📊 总时长:{timeline_cursor / SEC:.2f} 秒,共 {len(CLIPS)} 个片段")
输出规范
- 先运行匹配:输出详细的匹配报告,向用户展示每个段落的匹配质量
- 用户确认后再写入脚本:等用户确认 CLIPS 列表没问题,再生成
generate_draft.py - 脚本放在工作区:
{workspace}/generate_draft.py,用户可直接python generate_draft.py - 未匹配段落处理:在 CLIPS 中以注释形式标注跳过,明确提示用户需手动补充
- 高亮低置信度:sim < 0.6 的条目在 CLIPS 注释中用 ⚠️ 标注
核心约束(必读)
| 约束 | 说明 |
|---|---|
| 禁止删除素材 | 绝不删除、移动、重命名、修改任何视频文件、SRT 文件。只读取。 |
| 口播稿完整匹配 | 每个段落都必须尝试匹配,不得静默丢弃。匹配失败/低置信度也要报告。 |
| SRT 优先 | 不运行 WhisperX。如果目录下没有 SRT 文件,告知用户先提供。 |
| 真实视频路径 | 直接用真实视频创建 VideoMaterial,不做假视频骨架、不做手动 JSON 替换。 |
| 先报告后写入 | 匹配结果必须先展示给用户审查,确认后再写入 generate_draft.py。 |
| 剪映版本 | pyjianyingdraft 目前支持剪映 5.9 及以下版本。 |
常见问题
| 问题 | 处理方式 |
|---|---|
| SRT 文件不存在 | 提示用户视频目录必须有 _原文.srt 文件,或让用户指定 SRT 文件路径 |
| 段落匹配得分低 | 标注 ⚠️,给出原视频中对应时间段的字幕文本,让用户判断是否可用 |
| 某段落完全未匹配 | 在 CLIPS 中注释掉,提示用户手动在剪映中补充 |
| 视频只有部分被用到 | 正常现象 — 口播稿是筛选后的内容,未用到的视频片段不出现在草稿中 |
| 时间线上有片段但视频不显示 | 检查视频路径是否正确、网络路径是否可访问。不要用假视频骨架 |
| 口播稿不是空行分段 | 询问用户段落的实际分隔方式,调整 split 逻辑 |
| 视频文件扩展名不匹配 | 自动尝试 .MP4/.mp4/.MOV/.mov/.mkv/.avi 等常见扩展名 |
| 匹配太慢(9915 条) | 已用 SequenceMatcher(C实现)替代暴力 LCS 窗口,<5s 完成 8 段匹配 |