自拍视频剪辑
将一个或多个原始的自拍视频素材(手机拍摄、桌面摄像头、自拍镜头)合并为一个连贯的成片: 每条台词只出现一次,选择最佳(通常是最后一次)的清晰尝试,没有磕绊或重复的词语, 单词结尾不会被剪掉,拼接点以字为单位紧密切合(拼接处约有0-40ms的室内环境音,出点设置在声音衰减的尾端,入点设置在声音上升的起始), 画面构图使面部位于预设的目标区域(宽景/近景交替),每个片段的声音清晰可闻(语音音量均衡), 达到广播级响度,输出为2160x3840分辨率的竖版视频。
所有脚本都位于此文件旁边的 scripts/ 目录下(包括转码、规划、区域探测、
音频合成、重新构图、视频合成、验证、通用工具、情感过渡和口型相关脚本)。临时文件夹会在每次任务间清空,
所以永远不要依赖临时目录中的临时脚本:不要复制任何文件,直接调用这些脚本。
SK=~/.claude/skills/talking-head-edit/scripts
PY=$($SK/find_python.sh) # 优先使用 $REELKIT_PYTHON,否则使用 <repo>/.venv/bin/python3,最后使用 python3(需要安装 faster-whisper 和 opencv)
FFMPEG:使用 $FFMPEG,否则使用 PATH 中的 ffmpeg(common.py);不需要 drawtext 滤镜
YuNet 人脸模型:使用 $YUNET_MODEL,否则使用 <repo>/assets/models/yunet.onnx(<repo> 指 $REELKIT_HOME 或此技能所在的仓库)
在全新的临时目录($W)中工作。切勿覆盖用户之前的导出文件;导出文件命名为 _v2、_v3。
流水线
1. 探测每个源文件
执行 ffprobe -show_entries stream=width,height:stream_side_data=rotation。iPhone 竖屏拍摄的视频
通常存储为 3840x2160 并带有 rotation=-90 的旋转标志;ffmpeg 在解码时会自动旋转,因此剪辑出来的源文件是竖屏的。
如果某个源文件旋转后的方向与输出方向不匹配,build_video.py 将拒绝运行。如果一个没有旋转标志的片段看起来是横屏的,
则需要使用 transpose 滤镜。
2. 转录所有源文件(推荐使用 medium.en 或更大的模型,需要时间戳)
$PY $SK/transcribe.py SRC1.MOV SRC2.MOV --model large-v3 --out-dir $W --words > $W/transcript.txt
默认分块处理(30秒窗口,6秒重叠)。如果对一段很长的原始素材进行一次性的 Whisper 转录, 可能会无声无息地丢失整分钟的重拍内容;分块处理能确保每次尝试都可被看到。使用 large-v3 模型在 CPU 上运行 大约需要素材长度的1.5-2倍时间;medium.en 是最低要求。
3. 重拍审计(必须执行,第一个版本失败的原因就在这里)
从头到尾通读完整转录文本,并在选择剪切点之前构建一个按行划分的表格:
| 行号 | 尝试次数(开始-结束,词) | 选中片段 | 原因 |
规则:
- 按意图对尝试进行分组(即使措辞发生变化)。创作者通常 会重拍直到满意为止,所以最后一次完整的、清晰的尝试胜出,除非它明显更差 (磕绊、被截断、笑场)。后续尝试中措辞的变化是故意的。
- 保留的片段必须不包含内部重新开始、磕绊或重复的词。注意 拉长的单词(“re... built”)、重复的单词(“I don't know about you, I don't know about you”)以及行内较长的停顿(可能是重新开始的迹象)。如果选定的 尝试存在这些问题,则找到一条清晰的尝试,或在包络线上将磕绊部分剪切掉。
- 不能有两个保留的片段覆盖重叠的内容。 如果片段 N 以短语 X 结尾,而 片段 N+1 以短语 X 开头,或者它们是同一句话两个不同尝试的前后半部分, 那就是拼接的重拍。只有在声音(语气、节奏)和画面方面衔接无缝的情况下, 才可以将两个尝试在句子中间拼接在一起。
- 画面检查:同一缩放级别下相邻的片段如果在姿势或手势上出现跳跃, 则表明存在重拍。交替缩放可以掩盖小的跳跃,但不能掩盖内容重复。
- 还要检查那些从未被使用的行:确保最终脚本中的每一行都恰好出现 一次。
- 重复词扫描(必须)。 列出选定片段中的每一个重复:重复的单词 (“usually, usually”, “like, like”)、重新开始的短语 (“I've actually found one tool. But one of the tools...”)、以及在视频中出现两次的同一号召性用语(例如在视频中间和 结尾都出现“comment EDIT”)。在包络线的凹陷处将每个重复剪切为一个单一的清晰实例 (如有必要,可以将一个片段在重复的填充词周围分成 a/b 两部分)。对于重复的号召性用语, 保留一个措辞最强有力的,放在结尾;删除另一个以及任何指向它的后续语句 (“And again, comment ...”)。verify.py 会对输出结果执行相同的扫描(步骤8), 但最好在这里先发现它们。
口语化的编辑指令。 创作者经常在原始素材中对着剪辑师说话(“你可以把所有这些 都剪掉”、“用第二个”、“跳过这个”)。在选择剪切点之前,列出每个 针对剪辑师的言论及其时间戳,遵照执行,并确保没有指令性语言 出现在保留的片段中。如果指令范围模糊(例如“所有那些在结尾的部分”是否包含 结束的号召性用语):选择符合已陈述理由的解读,并在报告中标记出来。 对于你无法执行的指令(你没有的 B-roll 素材、字幕、音乐),要在报告中说明, 绝不能伪造。
用于审计的工具:
plan.py PLAN --bursts SRC列出源文件中每个有声片段以及一个现成的范围列表; 将其传递给probe_regions.py,以便对每次尝试(包括长转录可能合并或遗漏的尝试) 进行独立转录。标记为“You”/“Thank you”的较长空白区间是 Whisper 对室内环境音的错误判断; 请使用包络线确认。plan.py PLAN --peek SRC LO HI [LO HI ...] --step 0.02打印包络线;在尝试之间的 每个有声片段都必须被考虑。probe_regions.py SRC LO-HI ...对指定时间窗口进行独立转录。在候选片段内部的 每个包络线有声片段上运行它。当两个紧密相连的重复词都在同一个时间窗口内时,Whisper 会 将它们合并成一个长单词:在一次剪辑中,“so then we re-”(中断)- “so then we rebuilt it”(完整)被转录为单个 “re- ... -built”,持续 2.2 秒。任何时长超过约 0.6 秒的词语都是危险信号; 将该区域在包络线凹陷处拆分,并分别转录每个部分。
向用户(或在报告中)展示该表格,并说明每一行选定的尝试。 判断:如果最后一次尝试直接流入下一个短语,包络线上没有明显的下降(在 >= 60ms 内没有低于阈值),\n则无法在帧边界上干净地剪切;此时,具有自然结尾的较早尝试胜出。在表格中注明这一点。
4. 编写计划
创建 $W/plan.json(参见 plan.example.json):按标题列出源文件,然后按叙述顺序列出片段。
对于每个片段,给出 lo/hi 硬边界,这些边界应放在选定尝试前后的静音区域中(绝不能放在相邻尝试内部),
ws/we = Whisper 识别出的第一个词的开始时间 / 最后一个词的结束时间,text = 预期的词语,
可选的 compress:false(保留自然停顿),可选的 zoom。使用 --peek SRC LO HI 打印包络线并放置边界。
对于采访或播客素材(双机位,主持人 + 嘉宾):在编写计划前,也请阅读下面的采访/播客(双机位)
部分(故事顺序、发言人检查、story 标签)。
4b. 内容修剪(基于规则,在编写计划时应用)
从比较一次导出和一个手工重剪辑版本中学习的经验。应用以下规则;判断部分仅是 在报告中提出的建议,不应静默应用。
- 规则:删除片段开头的“So”。 手工重新剪辑去除了所有以“So”开头的片段中的它。
将
lo/ws放置在“So”和下一个词之间的包络线凹陷处(以5-10ms步长--peek;该 凹陷通常为 -45..-53 dB,在下一个词开始前10-20ms)。保留片段开头的“And”/“But”。 - 规则:在号召性用语处结束。 删除其后的任何结束语 (“Hopefully this helps.”, “See you next time”)。
- 规则:剪切较长的行内犹豫(句子内 >= ~200ms 的静音,且没有丢失任何词语)。
- 判断(手工重剪辑执行了这些,请询问或标记):一个不增加信息的评论性旁白 (“And that is basically the whole idea.”)、一个含糊的结尾标签 (“...the whole thing”)、在声明之前的一个填充性强调词 (“It's really that simple” -> “It's that simple”)。它保留了 “like”、“actually”、“Best part is”, 因此没有通用的填充词规则。
5. 将剪切点对齐到包络线(默认紧密边缘)
$PY $SK/plan.py $W/plan.json --out $W/cuts.json
$PY $SK/plan.py $W/plan.json --peek B LO HI --step 0.01 # 时间:宽带/2-8kHz dB + 本地本底噪声
默认 tail_mode: “tight”(v9)。 依据一个手工重剪辑版本进行测量,该版本在单词上
进行了紧密拼接:出点中位数在 -47 dB 交叉点之前 58ms(在下降的尾端,比室内环境音高约 15 dB),
入点约在上升起始点之后 12ms,拼接处约有 0-5ms 的室内环境音(早期导出有 87ms)。
没有词丢失;剪辑故意比自然衰减更紧。紧密模式匹配这种风格:
- 出点 = 在 -47 交叉点之前的
tight_max_trim(200ms)内,包络线最后一个帧高于max(local floor, thresh-6) + tight_edge_db(15 dB);入点 = 在起始点之后的 200ms 内,第一个高于该水平的帧。tight_out_offset/tight_in_offset(0)可偏移它们;没有安全尾音,没有手柄,没有拼接填充。目标真空间隙tight_join_gap[0, 40ms]。 next-word结尾仍然在下一个词之前的凹陷处(+5ms)被限制,带有其淡出。- 帧对齐 = 最近的整帧(
fit_frames_tight),如果会越过 hi 则向后偏移。 - plan.py 打印每个片段的
tight: out Xms vs -47 crossing, in Yms vs -47 onset;verify.py 打印每个拼接点的有符号边缘及参考值(出点 -58 / 入点 -37 中位数)。在最后一个词的选通中,仍必须能听到完整的词;如果有片段失败,则为该片段单独提高tight_out_offset,而不是全局调整。 - 行内停顿不变(参考保留了压缩后的停顿,最长125ms):
pause_min100ms,pause_keep_tail50ms,行内衰减逻辑。 tail_mode: “decay”恢复 v8(“从不剪切到词”,约 65-110ms 拼接),如下所述;当需要更慢、更有呼吸感的表达或用户要求行间有气息时使用。
tail_mode: “decay”(v8)。-47 dB 交叉点并不是一个词的结束。早期版本会基于此进行剪切(带25ms/30ms的手柄)并剪掉了“free”和“thing”。衰减模式(common.py):
- 两个包络线,20ms RMS / 5ms 跳跃:宽带(90 Hz 高通)和 2-8 kHz 频段(
hf_band), f/s/th 和带呼吸音的元音(“free”)的能量集中于此。本地本底噪声 = 在 +/-2.5s 窗口内的第10个百分点(floor_win), 上限为源文件全局本底噪声 + 3 dB。 - 出点:从 Whisper 词尾附近最后一个高于 -47 的帧开始,跟踪该词直到它真正衰减完毕:在
decay_hold(30ms)时间内, 宽带在decay_margin_db(6)内且 2-8 kHz 在decay_margin_hf_db(10)内接近本地本底噪声,最多decay_max(450ms)。 然后保留一个 安全尾音post_offset= 80ms(帧对齐可能将其修剪为post_min65ms,绝不会更低)。 - 如果下一个词紧随其后没有静音(重新上升
rise_db8 dB,或者 hi 放置在凹陷处且源文件在其后立即响度较高), 则出点为该凹陷,标记为next-word:一个 25ms 的淡出(bounded_fade)在剪切点结束,且没有交叉淡入溢过它, 这样结尾是衰减而不是咔哒声,且下一个词永远不会泄漏进来。将hi放置在该凹陷上(以 5-10ms 步长--peek)。 - 入点保持紧:从第一个高于 -47 的帧开始,跟随一个软起始(摩擦音、呼吸音)回溯到
attack_max80ms, 保留pre_onset15ms。在其他语音之后紧接着的入点会有一个 12ms 的淡入。 - 帧对齐舍入到整帧,不侵占任何词(
fit_frames_grow)。 - 拼接填充:每个拼接点至少获得
join_gap[0](60ms)的真实静音(上一次衰减之后的室内环境音 + 下一次起始之前的室内环境音)。next-word结尾本身没有静音,因此由相邻片段从其 own lo/hi 内部的安静室内环境音提供。典型的拼接点落在 65-110ms 的室内环境音之间;plan.py 打印表格并标记<-- short/<-- long。 - 行内停顿 >=
pause_min使用相同的衰减逻辑被压缩(词后pause_keep_tail50ms,下一个词前pre_onset+10ms)。 - Whisper 词边缘有 50-300ms 的偏差。永远不要基于 Whisper 的时间进行剪切;它们仅用于搜索的初始位置。
plan.py打印每个片段的信息:阈值交叉点、起始点、衰减点(原因:衰减 / next-word / 边界)、 和衰减运行超出 -47 交叉点的距离(通常 50-170ms)。WARN tail still decaying at the hi bound= 将hi向外移动(进入静音区或到下一个词之前的凹陷处)。绝不要缩短尾部。tail_mode: “thresh”恢复 v2-v7 的行为(仅用于复现旧的导出)。- 跨版本的固定构图信息:计划片段中的
crop/zoom/track/face会传递到 cuts.json(从之前的 cuts.json 复制),因此时间调整不会改变构图。
5b. 插入片段(单独录制的片段 + B-roll),可选
在其他地方录制的片段(例如稍后录制的补录片段)可以放在两个片段之间,并配上 B-roll 图片覆盖, 这样观众的视线不会看到嘴形:
{“label”: “INS_x”, “insert”: true, “audio”: “/abs/line.wav”, “video”: “/abs/broll.MOV”,
“video_start”: 12.3, “video_x”: 0.5, “video_zoom”: 1.0, “text”: “Same voice, different language.”}
- 如果
audio文件尚不存在,plan.py 会打印PENDING并跳过该片段:计划可以先构建, 一旦文件被放置到该路径,重新运行步骤 5-8 就会处理它。绝不要使用 TTS 来伪造缺失的片段。 - 音频变成源
INS_<label>(两端填充 0.5s,settings.source_pad),应用相同的边缘逻辑, 与主源进行电平匹配(允许任何剪切,因为精制片段通常接近 -14 LUFS; 增益上限为insert_match_db12 dB)。目标是插入点前后片段的响度,而不是整个源文件;每个片段的gain_db可对其进行微调。 检查它在输出中与相邻片段的差距在 1 LU 以内。它将建立在主源文件的室内环境音之上(最安静的 1s,循环播放;insert_room_tone),这样听起来不会像静音故障。 - 为了在不剪切词的情况下将一个拼接点的间隔调整到 60-100ms 范围内(只需一帧),片段可以携带自己的
post_min(例如 0.060)以及更紧的hi;默认值为 0.065。 - 画面:
video从video_start开始,持续时长恰好等于插入片段的时长,缩放以覆盖 2160x3840 (横向 B-roll 在video_x处中心裁剪)。插入片段不计入宽/近景交替。 - 视频中间文件 […]
[原文被截断,翻译至此]