All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s
Co-authored-by: Cursor <cursoragent@cursor.com>
175 lines
5.0 KiB
Python
175 lines
5.0 KiB
Python
"""FFmpeg 合成:帧序列 + SRT 字幕 + 背景音乐 → MP4。"""
|
||
from __future__ import annotations
|
||
|
||
import subprocess
|
||
import sys
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
from jiangchang_skill_core.media_assets import (
|
||
background_music_issue,
|
||
pick_background_music,
|
||
resolve_ffmpeg,
|
||
)
|
||
|
||
# 匠厂统一字幕样式 —— 所有 skill 录屏共用,做品牌一致性
|
||
_JIANGCHANG_SUBTITLE_STYLE = (
|
||
"FontName=Arial,FontSize=14,"
|
||
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
|
||
"Outline=2,Shadow=1,Alignment=2"
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class ComposeVideoResult:
|
||
output_path: str
|
||
warnings: list[str] = field(default_factory=list)
|
||
|
||
|
||
def _escape_srt_path(path: str) -> str:
|
||
return path.replace("\\", "/").replace(":", "\\:")
|
||
|
||
|
||
def _run_ffmpeg(ffmpeg_exe: Path, cmd_tail: list[str]) -> tuple[bool, str]:
|
||
cmd = [str(ffmpeg_exe), *cmd_tail]
|
||
try:
|
||
result = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
)
|
||
except FileNotFoundError:
|
||
return False, "ffmpeg not found"
|
||
if result.returncode != 0:
|
||
err = (result.stderr or result.stdout or "").strip()
|
||
return False, err[:500] if err else f"exit {result.returncode}"
|
||
return True, ""
|
||
|
||
|
||
def _media_assets_env(
|
||
media_assets_root: str | Path | None,
|
||
media_assets_env: dict[str, str] | None,
|
||
) -> dict[str, str] | None:
|
||
if media_assets_root is None and media_assets_env is None:
|
||
return None
|
||
env = dict(media_assets_env or {})
|
||
if media_assets_root is not None:
|
||
env["MEDIA_ASSETS_ROOT"] = str(media_assets_root)
|
||
return env
|
||
|
||
|
||
def compose_video(
|
||
frames_dir: str,
|
||
fps: int,
|
||
subtitle_path: str,
|
||
output_path: str,
|
||
*,
|
||
music_volume: float = 0.15,
|
||
allow_no_music: bool = True,
|
||
media_assets_root: str | Path | None = None,
|
||
media_assets_env: dict[str, str] | None = None,
|
||
) -> ComposeVideoResult:
|
||
"""合成 MP4;ffmpeg 与背景音乐均走 jiangchang_skill_core.media_assets。"""
|
||
frames_dir_path = Path(frames_dir)
|
||
subtitle_path_obj = Path(subtitle_path)
|
||
output_path_obj = Path(output_path)
|
||
output_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||
warnings: list[str] = []
|
||
|
||
assets_env = _media_assets_env(media_assets_root, media_assets_env)
|
||
|
||
ffmpeg_exe = resolve_ffmpeg(env=assets_env)
|
||
if ffmpeg_exe is None or not Path(ffmpeg_exe).is_file():
|
||
raise RuntimeError("ffmpeg_not_found")
|
||
|
||
ffmpeg_path = Path(ffmpeg_exe)
|
||
music_file = pick_background_music(env=assets_env)
|
||
if music_file is None:
|
||
issue = background_music_issue(env=assets_env)
|
||
if issue:
|
||
warnings.append(issue)
|
||
else:
|
||
warnings.append("background_music_missing")
|
||
if not allow_no_music:
|
||
raise FileNotFoundError(warnings[-1])
|
||
|
||
srt_esc = _escape_srt_path(str(subtitle_path_obj.resolve()))
|
||
subtitle_filter = (
|
||
f"subtitles='{srt_esc}'"
|
||
f":force_style='{_JIANGCHANG_SUBTITLE_STYLE}'"
|
||
)
|
||
|
||
if music_file and music_file.is_file():
|
||
filter_complex = (
|
||
f"[0:v]{subtitle_filter}[vout];"
|
||
f"[1:a]volume={music_volume},apad[aout]"
|
||
)
|
||
cmd_tail = [
|
||
"-y",
|
||
"-framerate",
|
||
str(fps),
|
||
"-i",
|
||
str(frames_dir_path / "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_obj),
|
||
]
|
||
ok, err = _run_ffmpeg(ffmpeg_path, cmd_tail)
|
||
if not ok:
|
||
warnings.append(f"ffmpeg_with_music_failed: {err}")
|
||
if not allow_no_music:
|
||
raise RuntimeError(f"ffmpeg_compose_failed: {err}")
|
||
music_file = None
|
||
else:
|
||
print(f"[screencast] 背景音乐: {music_file.name}")
|
||
print(f"[screencast] 输出: {output_path_obj}")
|
||
return ComposeVideoResult(str(output_path_obj.resolve()), warnings)
|
||
|
||
cmd_tail = [
|
||
"-y",
|
||
"-framerate",
|
||
str(fps),
|
||
"-i",
|
||
str(frames_dir_path / "frame_%06d.png"),
|
||
"-vf",
|
||
subtitle_filter,
|
||
"-c:v",
|
||
"libx264",
|
||
"-preset",
|
||
"fast",
|
||
"-crf",
|
||
"23",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
str(output_path_obj),
|
||
]
|
||
print("[screencast] 运行 FFmpeg 合成(无背景音乐)...")
|
||
ok, err = _run_ffmpeg(ffmpeg_path, cmd_tail)
|
||
if not ok:
|
||
print(err, file=sys.stderr)
|
||
raise RuntimeError(f"ffmpeg_compose_failed: {err}")
|
||
|
||
print(f"[screencast] 输出: {output_path_obj}")
|
||
return ComposeVideoResult(str(output_path_obj.resolve()), warnings)
|