151 lines
4.7 KiB
Python
151 lines
4.7 KiB
Python
"""
|
||
Remotion 视频渲染服务
|
||
调用 Node.js Remotion 进行视频合成(字幕 + 标题)
|
||
"""
|
||
|
||
import asyncio
|
||
import subprocess
|
||
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 = 3.0,
|
||
fps: int = 25,
|
||
enable_subtitles: bool = True,
|
||
on_progress: Optional[callable] = None
|
||
) -> str:
|
||
"""
|
||
使用 Remotion 渲染视频(添加字幕和标题)
|
||
|
||
Args:
|
||
video_path: 输入视频路径(唇形同步后的视频)
|
||
output_path: 输出视频路径
|
||
captions_path: 字幕 JSON 文件路径(Whisper 生成)
|
||
title: 视频标题(可选)
|
||
title_duration: 标题显示时长(秒)
|
||
fps: 帧率
|
||
enable_subtitles: 是否启用字幕
|
||
on_progress: 进度回调函数
|
||
|
||
Returns:
|
||
输出视频路径
|
||
"""
|
||
# 构建命令参数
|
||
cmd = [
|
||
"npx", "ts-node", "render.ts",
|
||
"--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)])
|
||
|
||
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
|
||
)
|
||
|
||
output_lines = []
|
||
for line in iter(process.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:
|
||
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()
|