You've already forked FrameTour-RenderWorker
feat(render): 实现渲染系统v2核心架构
- 添加v2支持的任务类型常量定义 - 更新软件版本至0.0.9 - 定义v2统一音视频编码参数 - 实现系统信息工具get_sys_info_v2方法 - 新增get_capabilities和_get_gpu_info功能 - 创建core模块及TaskHandler抽象基类 - 添加渲染系统设计文档包括集群架构、v2 PRD和Worker PRD - 实现任务处理器抽象基类及接口规范
This commit is contained in:
280
handlers/base.py
Normal file
280
handlers/base.py
Normal file
@@ -0,0 +1,280 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
任务处理器基类
|
||||
|
||||
提供所有处理器共用的基础功能。
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import subprocess
|
||||
from abc import ABC
|
||||
from typing import Optional, List, TYPE_CHECKING
|
||||
|
||||
from core.handler import TaskHandler
|
||||
from domain.task import Task
|
||||
from domain.result import TaskResult, ErrorCode
|
||||
from domain.config import WorkerConfig
|
||||
from util import oss
|
||||
from util.ffmpeg import subprocess_args
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from services.api_client import APIClientV2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# v2 统一视频编码参数(来自集成文档)
|
||||
VIDEO_ENCODE_ARGS = [
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'medium',
|
||||
'-profile:v', 'main',
|
||||
'-level', '4.0',
|
||||
'-crf', '23',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
]
|
||||
|
||||
# v2 统一音频编码参数
|
||||
AUDIO_ENCODE_ARGS = [
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-ar', '48000',
|
||||
'-ac', '2',
|
||||
]
|
||||
|
||||
|
||||
class BaseHandler(TaskHandler, ABC):
|
||||
"""
|
||||
任务处理器基类
|
||||
|
||||
提供所有处理器共用的基础功能,包括:
|
||||
- 临时目录管理
|
||||
- 文件下载/上传
|
||||
- FFmpeg 命令执行
|
||||
- 日志记录
|
||||
"""
|
||||
|
||||
def __init__(self, config: WorkerConfig, api_client: 'APIClientV2'):
|
||||
"""
|
||||
初始化处理器
|
||||
|
||||
Args:
|
||||
config: Worker 配置
|
||||
api_client: API 客户端
|
||||
"""
|
||||
self.config = config
|
||||
self.api_client = api_client
|
||||
|
||||
def before_handle(self, task: Task) -> None:
|
||||
"""处理前钩子"""
|
||||
logger.debug(f"[task:{task.task_id}] Before handle: {task.task_type.value}")
|
||||
|
||||
def after_handle(self, task: Task, result: TaskResult) -> None:
|
||||
"""处理后钩子"""
|
||||
status = "success" if result.success else "failed"
|
||||
logger.debug(f"[task:{task.task_id}] After handle: {status}")
|
||||
|
||||
def create_work_dir(self, task_id: str = None) -> str:
|
||||
"""
|
||||
创建临时工作目录
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID(用于目录命名)
|
||||
|
||||
Returns:
|
||||
工作目录路径
|
||||
"""
|
||||
# 确保临时根目录存在
|
||||
os.makedirs(self.config.temp_dir, exist_ok=True)
|
||||
|
||||
# 创建唯一的工作目录
|
||||
prefix = f"task_{task_id}_" if task_id else "task_"
|
||||
work_dir = tempfile.mkdtemp(dir=self.config.temp_dir, prefix=prefix)
|
||||
|
||||
logger.debug(f"Created work directory: {work_dir}")
|
||||
return work_dir
|
||||
|
||||
def cleanup_work_dir(self, work_dir: str) -> None:
|
||||
"""
|
||||
清理临时工作目录
|
||||
|
||||
Args:
|
||||
work_dir: 工作目录路径
|
||||
"""
|
||||
if not work_dir or not os.path.exists(work_dir):
|
||||
return
|
||||
|
||||
try:
|
||||
shutil.rmtree(work_dir)
|
||||
logger.debug(f"Cleaned up work directory: {work_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup work directory {work_dir}: {e}")
|
||||
|
||||
def download_file(self, url: str, dest: str, timeout: int = None) -> bool:
|
||||
"""
|
||||
下载文件
|
||||
|
||||
Args:
|
||||
url: 文件 URL
|
||||
dest: 目标路径
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
if timeout is None:
|
||||
timeout = self.config.download_timeout
|
||||
|
||||
try:
|
||||
result = oss.download_from_oss(url, dest)
|
||||
if result:
|
||||
file_size = os.path.getsize(dest) if os.path.exists(dest) else 0
|
||||
logger.debug(f"Downloaded: {url} -> {dest} ({file_size} bytes)")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Download failed: {url} -> {e}")
|
||||
return False
|
||||
|
||||
def upload_file(
|
||||
self,
|
||||
task_id: str,
|
||||
file_type: str,
|
||||
file_path: str,
|
||||
file_name: str = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
上传文件并返回访问 URL
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
file_type: 文件类型(video/audio/ts/mp4)
|
||||
file_path: 本地文件路径
|
||||
file_name: 文件名(可选)
|
||||
|
||||
Returns:
|
||||
访问 URL,失败返回 None
|
||||
"""
|
||||
# 获取上传 URL
|
||||
upload_info = self.api_client.get_upload_url(task_id, file_type, file_name)
|
||||
if not upload_info:
|
||||
logger.error(f"[task:{task_id}] Failed to get upload URL")
|
||||
return None
|
||||
|
||||
upload_url = upload_info.get('uploadUrl')
|
||||
access_url = upload_info.get('accessUrl')
|
||||
|
||||
if not upload_url:
|
||||
logger.error(f"[task:{task_id}] Invalid upload URL response")
|
||||
return None
|
||||
|
||||
# 上传文件
|
||||
try:
|
||||
result = oss.upload_to_oss(upload_url, file_path)
|
||||
if result:
|
||||
file_size = os.path.getsize(file_path)
|
||||
logger.info(f"[task:{task_id}] Uploaded: {file_path} ({file_size} bytes)")
|
||||
return access_url
|
||||
else:
|
||||
logger.error(f"[task:{task_id}] Upload failed: {file_path}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[task:{task_id}] Upload error: {e}")
|
||||
return None
|
||||
|
||||
def run_ffmpeg(
|
||||
self,
|
||||
cmd: List[str],
|
||||
task_id: str,
|
||||
timeout: int = None
|
||||
) -> bool:
|
||||
"""
|
||||
执行 FFmpeg 命令
|
||||
|
||||
Args:
|
||||
cmd: FFmpeg 命令参数列表
|
||||
task_id: 任务 ID(用于日志)
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
if timeout is None:
|
||||
timeout = self.config.ffmpeg_timeout
|
||||
|
||||
# 日志记录命令(限制长度)
|
||||
cmd_str = ' '.join(cmd)
|
||||
if len(cmd_str) > 500:
|
||||
cmd_str = cmd_str[:500] + '...'
|
||||
logger.info(f"[task:{task_id}] FFmpeg: {cmd_str}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
**subprocess_args(False)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode('utf-8', errors='replace')[:1000]
|
||||
logger.error(f"[task:{task_id}] FFmpeg failed (code={result.returncode}): {stderr}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"[task:{task_id}] FFmpeg timeout after {timeout}s")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[task:{task_id}] FFmpeg error: {e}")
|
||||
return False
|
||||
|
||||
def probe_duration(self, file_path: str) -> Optional[float]:
|
||||
"""
|
||||
探测媒体文件时长
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
时长(秒),失败返回 None
|
||||
"""
|
||||
try:
|
||||
from util.ffmpeg import probe_video_info
|
||||
_, _, duration = probe_video_info(file_path)
|
||||
return float(duration) if duration else None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to probe duration: {file_path} -> {e}")
|
||||
return None
|
||||
|
||||
def get_file_size(self, file_path: str) -> int:
|
||||
"""
|
||||
获取文件大小
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
文件大小(字节)
|
||||
"""
|
||||
try:
|
||||
return os.path.getsize(file_path)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def ensure_file_exists(self, file_path: str, min_size: int = 0) -> bool:
|
||||
"""
|
||||
确保文件存在且大小满足要求
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
min_size: 最小大小(字节)
|
||||
|
||||
Returns:
|
||||
是否满足要求
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
return False
|
||||
return os.path.getsize(file_path) >= min_size
|
||||
Reference in New Issue
Block a user