Files
ViGent2/backend/app/services/remotion_service.py
Kevin Wong 0a5a17402c 更新
2026-02-24 16:55:29 +08:00

195 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()