195 lines
6.7 KiB
Python
195 lines
6.7 KiB
Python
"""
|
||
Remotion 视频渲染服务
|
||
调用 Node.js Remotion 进行视频合成(字幕 + 标题)
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import subprocess
|
||
from collections.abc import Callable
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
from loguru import logger
|
||
|
||
|
||
class RemotionService:
|
||
"""Remotion 视频渲染服务"""
|
||
|
||
def __init__(self, remotion_dir: Optional[str] = None):
|
||
# Remotion 项目目录
|
||
if remotion_dir:
|
||
self.remotion_dir = Path(remotion_dir)
|
||
else:
|
||
# 默认在 ViGent2/remotion 目录
|
||
self.remotion_dir = Path(__file__).parent.parent.parent.parent / "remotion"
|
||
|
||
async def render(
|
||
self,
|
||
video_path: str,
|
||
output_path: str,
|
||
captions_path: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
title_duration: float = 4.0,
|
||
title_display_mode: str = "short",
|
||
fps: int = 25,
|
||
enable_subtitles: bool = True,
|
||
subtitle_style: Optional[dict] = None,
|
||
title_style: Optional[dict] = None,
|
||
secondary_title: Optional[str] = None,
|
||
secondary_title_style: Optional[dict] = None,
|
||
on_progress: Optional[Callable[[int], None]] = None
|
||
) -> str:
|
||
"""
|
||
使用 Remotion 渲染视频(添加字幕和标题)
|
||
|
||
Args:
|
||
video_path: 输入视频路径(唇形同步后的视频)
|
||
output_path: 输出视频路径
|
||
captions_path: 字幕 JSON 文件路径(Whisper 生成)
|
||
title: 视频标题(可选)
|
||
title_duration: 标题显示时长(秒)
|
||
title_display_mode: 标题显示模式(short/persistent)
|
||
fps: 帧率
|
||
enable_subtitles: 是否启用字幕
|
||
on_progress: 进度回调函数
|
||
|
||
Returns:
|
||
输出视频路径
|
||
"""
|
||
# 构建命令参数
|
||
# 优先使用预编译的 JS 文件(更快),如果不存在则回退到 ts-node
|
||
compiled_js = self.remotion_dir / "dist" / "render.js"
|
||
if compiled_js.exists():
|
||
cmd = ["node", "dist/render.js"]
|
||
logger.info("Using pre-compiled render.js for faster startup")
|
||
else:
|
||
cmd = ["npx", "ts-node", "render.ts"]
|
||
logger.warning("Using ts-node (slower). Run 'npm run build:render' to compile for faster startup.")
|
||
|
||
cmd.extend([
|
||
"--video", str(video_path),
|
||
"--output", str(output_path),
|
||
"--fps", str(fps),
|
||
"--enableSubtitles", str(enable_subtitles).lower()
|
||
])
|
||
|
||
if captions_path:
|
||
cmd.extend(["--captions", str(captions_path)])
|
||
|
||
if title:
|
||
cmd.extend(["--title", title])
|
||
cmd.extend(["--titleDuration", str(title_duration)])
|
||
cmd.extend(["--titleDisplayMode", title_display_mode])
|
||
|
||
if subtitle_style:
|
||
cmd.extend(["--subtitleStyle", json.dumps(subtitle_style, ensure_ascii=False)])
|
||
|
||
if title_style:
|
||
cmd.extend(["--titleStyle", json.dumps(title_style, ensure_ascii=False)])
|
||
|
||
if secondary_title:
|
||
cmd.extend(["--secondaryTitle", secondary_title])
|
||
|
||
if secondary_title_style:
|
||
cmd.extend(["--secondaryTitleStyle", json.dumps(secondary_title_style, ensure_ascii=False)])
|
||
|
||
logger.info(f"Running Remotion render: {' '.join(cmd)}")
|
||
|
||
# 在线程池中运行子进程
|
||
def _run_render():
|
||
process = subprocess.Popen(
|
||
cmd,
|
||
cwd=str(self.remotion_dir),
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
bufsize=1
|
||
)
|
||
|
||
if process.stdout is None:
|
||
raise RuntimeError("Remotion process stdout is unavailable")
|
||
stdout = process.stdout
|
||
|
||
output_lines = []
|
||
for line in iter(stdout.readline, ''):
|
||
line = line.strip()
|
||
if line:
|
||
output_lines.append(line)
|
||
logger.debug(f"[Remotion] {line}")
|
||
|
||
# 解析进度
|
||
if "Rendering:" in line and "%" in line:
|
||
try:
|
||
percent_str = line.split("Rendering:")[1].strip().replace("%", "")
|
||
percent = int(percent_str)
|
||
if on_progress:
|
||
on_progress(percent)
|
||
except (ValueError, IndexError):
|
||
pass
|
||
|
||
process.wait()
|
||
|
||
if process.returncode != 0:
|
||
# Remotion 渲染可能在完成输出后进程崩溃 (如 SIGABRT code -6)
|
||
# 如果输出文件已存在且大小合理,视为成功
|
||
output_file = Path(output_path)
|
||
if output_file.exists() and output_file.stat().st_size > 1024:
|
||
logger.warning(
|
||
f"Remotion process exited with code {process.returncode}, "
|
||
f"but output file exists ({output_file.stat().st_size} bytes). Treating as success."
|
||
)
|
||
return output_path
|
||
|
||
error_msg = "\n".join(output_lines[-20:]) # 最后 20 行
|
||
raise RuntimeError(f"Remotion render failed (code {process.returncode}):\n{error_msg}")
|
||
|
||
return output_path
|
||
|
||
loop = asyncio.get_event_loop()
|
||
result = await loop.run_in_executor(None, _run_render)
|
||
|
||
logger.info(f"Remotion render complete: {result}")
|
||
return result
|
||
|
||
async def check_health(self) -> dict:
|
||
"""检查 Remotion 服务健康状态"""
|
||
try:
|
||
# 检查 remotion 目录是否存在
|
||
if not self.remotion_dir.exists():
|
||
return {
|
||
"ready": False,
|
||
"error": f"Remotion directory not found: {self.remotion_dir}"
|
||
}
|
||
|
||
# 检查 package.json 是否存在
|
||
package_json = self.remotion_dir / "package.json"
|
||
if not package_json.exists():
|
||
return {
|
||
"ready": False,
|
||
"error": "package.json not found"
|
||
}
|
||
|
||
# 检查 node_modules 是否存在
|
||
node_modules = self.remotion_dir / "node_modules"
|
||
if not node_modules.exists():
|
||
return {
|
||
"ready": False,
|
||
"error": "node_modules not found, run 'npm install' first"
|
||
}
|
||
|
||
return {
|
||
"ready": True,
|
||
"remotion_dir": str(self.remotion_dir)
|
||
}
|
||
|
||
except Exception as e:
|
||
return {
|
||
"ready": False,
|
||
"error": str(e)
|
||
}
|
||
|
||
|
||
# 全局服务实例
|
||
remotion_service = RemotionService()
|