- composer.py:抽出 _JIANGCHANG_SUBTITLE_STYLE 常量,FontSize 28→14 / Outline 2→2(保持),1080p 屏幕约占 1.3% 高度,演示视频留足画面呼吸感 - subtitle.py:generate_srt 加 post-process 排队,相邻字幕互相让位 (MIN_DURATION=1.0s / GAP=0.1s),任意时刻屏幕上最多 1 条字幕, 避免开头几秒密集触发的字幕叠成两行 - runner.py:subprocess.Popen 注入 PYTHONIOENCODING=utf-8 + PYTHONUTF8=1, 让 Windows 子进程 Python 强制 UTF-8 输出,runner 端 UTF-8 解码即对齐, 修中文乱码 所有 skill 录屏受益,向后兼容(非 Windows 系统 UTF-8 注入无副作用)。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
81 lines
2.6 KiB
Python
81 lines
2.6 KiB
Python
"""FFmpeg 合成:帧序列 + SRT 字幕 + 背景音乐 → MP4。"""
|
||
from __future__ import annotations
|
||
|
||
import random
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
|
||
# 匠厂统一字幕样式 —— 所有 skill 录屏共用,做品牌一致性
|
||
# FontSize 14:1080p 屏幕约占 1.3% 高度,演示视频留足画面呼吸感
|
||
# Outline 2:14 号字配 Outline 3 会显胖,2 已足够在浅色背景上保持可读
|
||
# 修改请评估对所有 skill 录屏的影响。
|
||
_JIANGCHANG_SUBTITLE_STYLE = (
|
||
"FontName=Arial,FontSize=14,"
|
||
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
|
||
"Outline=2,Shadow=1,Alignment=2"
|
||
)
|
||
|
||
|
||
def compose_video(
|
||
frames_dir: str,
|
||
fps: int,
|
||
subtitle_path: str,
|
||
music_dir: str,
|
||
output_path: str,
|
||
music_volume: float = 0.15,
|
||
) -> str:
|
||
frames_dir = Path(frames_dir)
|
||
subtitle_path = Path(subtitle_path)
|
||
output_path = Path(output_path)
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 随机选一首背景音乐
|
||
music_files = list(Path(music_dir).rglob("*.mp3"))
|
||
if not music_files:
|
||
raise FileNotFoundError(f"music_dir 下没有找到 MP3 文件: {music_dir}")
|
||
music_file = random.choice(music_files)
|
||
print(f"[screencast] 背景音乐: {music_file.name}")
|
||
|
||
# FFmpeg 字幕路径在 Windows 下需要转义冒号(subtitles 滤镜内部语法)
|
||
srt_path_str = str(subtitle_path).replace("\\", "/").replace(":", "\\:")
|
||
|
||
subtitle_filter = (
|
||
f"subtitles='{srt_path_str}'"
|
||
f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'"
|
||
)
|
||
|
||
# 视频和音频都放进 filter_complex,避免 -vf 与 -filter_complex 混用报错
|
||
filter_complex = (
|
||
f"[0:v]{subtitle_filter}[vout];"
|
||
f"[1:a]volume={music_volume},apad[aout]"
|
||
)
|
||
|
||
cmd = [
|
||
"ffmpeg", "-y",
|
||
"-framerate", str(fps),
|
||
"-i", str(frames_dir / "frame_%06d.png"),
|
||
"-i", str(music_file),
|
||
"-filter_complex", filter_complex,
|
||
"-map", "[vout]",
|
||
"-map", "[aout]",
|
||
"-c:v", "libx264",
|
||
"-preset", "fast",
|
||
"-crf", "23",
|
||
"-c:a", "aac",
|
||
"-b:a", "128k",
|
||
"-shortest",
|
||
"-pix_fmt", "yuv420p",
|
||
str(output_path),
|
||
]
|
||
|
||
print(f"[screencast] 运行 FFmpeg 合成...")
|
||
result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||
if result.returncode != 0:
|
||
print(result.stderr, file=sys.stderr)
|
||
raise RuntimeError(f"FFmpeg 合成失败,退出码 {result.returncode}")
|
||
|
||
print(f"[screencast] 输出: {output_path}")
|
||
return str(output_path)
|