Release v1.0.10: shared media_assets and screencast media_assets_root override
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
"""FFmpeg 合成:帧序列 + SRT 字幕 + 背景音乐 → MP4。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
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 录屏共用,做品牌一致性
|
||||
# FontSize 14:1080p 屏幕约占 1.3% 高度,演示视频留足画面呼吸感
|
||||
# Outline 2:14 号字配 Outline 3 会显胖,2 已足够在浅色背景上保持可读
|
||||
# 修改请评估对所有 skill 录屏的影响。
|
||||
_JIANGCHANG_SUBTITLE_STYLE = (
|
||||
"FontName=Arial,FontSize=14,"
|
||||
"PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,"
|
||||
@@ -18,63 +20,155 @@ _JIANGCHANG_SUBTITLE_STYLE = (
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
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)
|
||||
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] = []
|
||||
|
||||
# 随机选一首背景音乐
|
||||
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}")
|
||||
assets_env = _media_assets_env(media_assets_root, media_assets_env)
|
||||
|
||||
# FFmpeg 字幕路径在 Windows 下需要转义冒号(subtitles 滤镜内部语法)
|
||||
srt_path_str = str(subtitle_path).replace("\\", "/").replace(":", "\\:")
|
||||
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_path_str}'"
|
||||
f"subtitles='{srt_esc}'"
|
||||
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]"
|
||||
)
|
||||
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 = [
|
||||
"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),
|
||||
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] 运行 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)
|
||||
print(f"[screencast] 输出: {output_path_obj}")
|
||||
return ComposeVideoResult(str(output_path_obj.resolve()), warnings)
|
||||
|
||||
Reference in New Issue
Block a user