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:
24
domain/__init__.py
Normal file
24
domain/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
领域模型层
|
||||
|
||||
包含任务实体、结果、配置等核心数据结构。
|
||||
"""
|
||||
|
||||
from domain.task import Task, TaskType, TaskStatus, RenderSpec, OutputSpec, AudioSpec, AudioProfile
|
||||
from domain.result import TaskResult, ErrorCode, RETRY_CONFIG
|
||||
from domain.config import WorkerConfig
|
||||
|
||||
__all__ = [
|
||||
'Task',
|
||||
'TaskType',
|
||||
'TaskStatus',
|
||||
'RenderSpec',
|
||||
'OutputSpec',
|
||||
'AudioSpec',
|
||||
'AudioProfile',
|
||||
'TaskResult',
|
||||
'ErrorCode',
|
||||
'RETRY_CONFIG',
|
||||
'WorkerConfig',
|
||||
]
|
||||
122
domain/config.py
Normal file
122
domain/config.py
Normal file
@@ -0,0 +1,122 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Worker 配置模型
|
||||
|
||||
定义 Worker 运行时的配置参数。
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
# 默认支持的任务类型
|
||||
DEFAULT_CAPABILITIES = [
|
||||
"RENDER_SEGMENT_VIDEO",
|
||||
"PREPARE_JOB_AUDIO",
|
||||
"PACKAGE_SEGMENT_TS",
|
||||
"FINALIZE_MP4"
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkerConfig:
|
||||
"""
|
||||
Worker 配置
|
||||
|
||||
包含 Worker 运行所需的所有配置参数。
|
||||
"""
|
||||
# API 配置
|
||||
api_endpoint: str
|
||||
access_key: str
|
||||
worker_id: str
|
||||
|
||||
# 并发控制
|
||||
max_concurrency: int = 4
|
||||
|
||||
# 心跳配置
|
||||
heartbeat_interval: int = 5 # 秒
|
||||
|
||||
# 租约配置
|
||||
lease_extension_threshold: int = 60 # 秒,提前多久续期
|
||||
lease_extension_duration: int = 300 # 秒,每次续期时长
|
||||
|
||||
# 目录配置
|
||||
temp_dir: str = "/tmp/render_worker"
|
||||
|
||||
# 能力配置
|
||||
capabilities: List[str] = field(default_factory=lambda: DEFAULT_CAPABILITIES.copy())
|
||||
|
||||
# FFmpeg 配置
|
||||
ffmpeg_timeout: int = 3600 # 秒,FFmpeg 执行超时
|
||||
|
||||
# 下载/上传配置
|
||||
download_timeout: int = 300 # 秒,下载超时
|
||||
upload_timeout: int = 600 # 秒,上传超时
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> 'WorkerConfig':
|
||||
"""从环境变量创建配置"""
|
||||
|
||||
# API 端点,优先使用 V2 版本
|
||||
api_endpoint = os.getenv('API_ENDPOINT_V2') or os.getenv('API_ENDPOINT', '')
|
||||
if not api_endpoint:
|
||||
raise ValueError("API_ENDPOINT_V2 or API_ENDPOINT environment variable is required")
|
||||
|
||||
# Access Key
|
||||
access_key = os.getenv('ACCESS_KEY', '')
|
||||
if not access_key:
|
||||
raise ValueError("ACCESS_KEY environment variable is required")
|
||||
|
||||
# Worker ID
|
||||
worker_id = os.getenv('WORKER_ID', '100001')
|
||||
|
||||
# 并发数
|
||||
max_concurrency = int(os.getenv('MAX_CONCURRENCY', '4'))
|
||||
|
||||
# 心跳间隔
|
||||
heartbeat_interval = int(os.getenv('HEARTBEAT_INTERVAL', '5'))
|
||||
|
||||
# 租约配置
|
||||
lease_extension_threshold = int(os.getenv('LEASE_EXTENSION_THRESHOLD', '60'))
|
||||
lease_extension_duration = int(os.getenv('LEASE_EXTENSION_DURATION', '300'))
|
||||
|
||||
# 临时目录
|
||||
temp_dir = os.getenv('TEMP_DIR', os.getenv('TEMP', '/tmp/render_worker'))
|
||||
|
||||
# 能力列表
|
||||
capabilities_str = os.getenv('CAPABILITIES', '')
|
||||
if capabilities_str:
|
||||
capabilities = [c.strip() for c in capabilities_str.split(',') if c.strip()]
|
||||
else:
|
||||
capabilities = DEFAULT_CAPABILITIES.copy()
|
||||
|
||||
# FFmpeg 超时
|
||||
ffmpeg_timeout = int(os.getenv('FFMPEG_TIMEOUT', '3600'))
|
||||
|
||||
# 下载/上传超时
|
||||
download_timeout = int(os.getenv('DOWNLOAD_TIMEOUT', '300'))
|
||||
upload_timeout = int(os.getenv('UPLOAD_TIMEOUT', '600'))
|
||||
|
||||
return cls(
|
||||
api_endpoint=api_endpoint,
|
||||
access_key=access_key,
|
||||
worker_id=worker_id,
|
||||
max_concurrency=max_concurrency,
|
||||
heartbeat_interval=heartbeat_interval,
|
||||
lease_extension_threshold=lease_extension_threshold,
|
||||
lease_extension_duration=lease_extension_duration,
|
||||
temp_dir=temp_dir,
|
||||
capabilities=capabilities,
|
||||
ffmpeg_timeout=ffmpeg_timeout,
|
||||
download_timeout=download_timeout,
|
||||
upload_timeout=upload_timeout
|
||||
)
|
||||
|
||||
def get_work_dir_path(self, task_id: str) -> str:
|
||||
"""获取任务工作目录路径"""
|
||||
return os.path.join(self.temp_dir, f"task_{task_id}")
|
||||
|
||||
def ensure_temp_dir(self) -> None:
|
||||
"""确保临时目录存在"""
|
||||
os.makedirs(self.temp_dir, exist_ok=True)
|
||||
105
domain/result.py
Normal file
105
domain/result.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
任务结果模型
|
||||
|
||||
定义错误码、重试配置、任务结果等数据结构。
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
|
||||
class ErrorCode(Enum):
|
||||
"""错误码枚举"""
|
||||
E_INPUT_UNAVAILABLE = "E_INPUT_UNAVAILABLE" # 素材不可访问/404
|
||||
E_FFMPEG_FAILED = "E_FFMPEG_FAILED" # FFmpeg 执行失败
|
||||
E_UPLOAD_FAILED = "E_UPLOAD_FAILED" # 上传失败
|
||||
E_SPEC_INVALID = "E_SPEC_INVALID" # renderSpec 非法
|
||||
E_TIMEOUT = "E_TIMEOUT" # 执行超时
|
||||
E_UNKNOWN = "E_UNKNOWN" # 未知错误
|
||||
|
||||
|
||||
# 重试配置
|
||||
RETRY_CONFIG: Dict[ErrorCode, Dict[str, Any]] = {
|
||||
ErrorCode.E_INPUT_UNAVAILABLE: {
|
||||
'max_retries': 3,
|
||||
'backoff': [1, 2, 5] # 重试间隔(秒)
|
||||
},
|
||||
ErrorCode.E_FFMPEG_FAILED: {
|
||||
'max_retries': 2,
|
||||
'backoff': [1, 3]
|
||||
},
|
||||
ErrorCode.E_UPLOAD_FAILED: {
|
||||
'max_retries': 3,
|
||||
'backoff': [1, 2, 5]
|
||||
},
|
||||
ErrorCode.E_SPEC_INVALID: {
|
||||
'max_retries': 0, # 不重试
|
||||
'backoff': []
|
||||
},
|
||||
ErrorCode.E_TIMEOUT: {
|
||||
'max_retries': 2,
|
||||
'backoff': [5, 10]
|
||||
},
|
||||
ErrorCode.E_UNKNOWN: {
|
||||
'max_retries': 1,
|
||||
'backoff': [2]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskResult:
|
||||
"""
|
||||
任务结果
|
||||
|
||||
封装任务执行的结果,包括成功数据或失败信息。
|
||||
"""
|
||||
success: bool
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
error_code: Optional[ErrorCode] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def ok(cls, data: Dict[str, Any]) -> 'TaskResult':
|
||||
"""创建成功结果"""
|
||||
return cls(success=True, data=data)
|
||||
|
||||
@classmethod
|
||||
def fail(cls, error_code: ErrorCode, error_message: str) -> 'TaskResult':
|
||||
"""创建失败结果"""
|
||||
return cls(
|
||||
success=False,
|
||||
error_code=error_code,
|
||||
error_message=error_message
|
||||
)
|
||||
|
||||
def to_report_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
转换为上报格式
|
||||
|
||||
用于 API 上报时的数据格式转换。
|
||||
"""
|
||||
if self.success:
|
||||
return {'result': self.data}
|
||||
else:
|
||||
return {
|
||||
'errorCode': self.error_code.value if self.error_code else 'E_UNKNOWN',
|
||||
'errorMessage': self.error_message or 'Unknown error'
|
||||
}
|
||||
|
||||
def can_retry(self) -> bool:
|
||||
"""是否可以重试"""
|
||||
if self.success:
|
||||
return False
|
||||
if not self.error_code:
|
||||
return True
|
||||
config = RETRY_CONFIG.get(self.error_code, {})
|
||||
return config.get('max_retries', 0) > 0
|
||||
|
||||
def get_retry_config(self) -> Dict[str, Any]:
|
||||
"""获取重试配置"""
|
||||
if not self.error_code:
|
||||
return {'max_retries': 1, 'backoff': [2]}
|
||||
return RETRY_CONFIG.get(self.error_code, {'max_retries': 1, 'backoff': [2]})
|
||||
249
domain/task.py
Normal file
249
domain/task.py
Normal file
@@ -0,0 +1,249 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
任务领域模型
|
||||
|
||||
定义任务类型、任务实体、渲染规格、输出规格等数据结构。
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TaskType(Enum):
|
||||
"""任务类型枚举"""
|
||||
RENDER_SEGMENT_VIDEO = "RENDER_SEGMENT_VIDEO" # 渲染视频片段
|
||||
PREPARE_JOB_AUDIO = "PREPARE_JOB_AUDIO" # 生成全局音频
|
||||
PACKAGE_SEGMENT_TS = "PACKAGE_SEGMENT_TS" # 封装 TS 分片
|
||||
FINALIZE_MP4 = "FINALIZE_MP4" # 产出最终 MP4
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
"""任务状态枚举"""
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
SUCCESS = "SUCCESS"
|
||||
FAILED = "FAILED"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderSpec:
|
||||
"""
|
||||
渲染规格
|
||||
|
||||
用于 RENDER_SEGMENT_VIDEO 任务,定义视频渲染参数。
|
||||
"""
|
||||
crop_enable: bool = False
|
||||
crop_size: Optional[str] = None
|
||||
speed: str = "1.0"
|
||||
lut_url: Optional[str] = None
|
||||
overlay_url: Optional[str] = None
|
||||
effects: Optional[str] = None
|
||||
zoom_cut: bool = False
|
||||
video_crop: Optional[str] = None
|
||||
face_pos: Optional[str] = None
|
||||
transitions: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict]) -> 'RenderSpec':
|
||||
"""从字典创建 RenderSpec"""
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
crop_enable=data.get('cropEnable', False),
|
||||
crop_size=data.get('cropSize'),
|
||||
speed=str(data.get('speed', '1.0')),
|
||||
lut_url=data.get('lutUrl'),
|
||||
overlay_url=data.get('overlayUrl'),
|
||||
effects=data.get('effects'),
|
||||
zoom_cut=data.get('zoomCut', False),
|
||||
video_crop=data.get('videoCrop'),
|
||||
face_pos=data.get('facePos'),
|
||||
transitions=data.get('transitions')
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputSpec:
|
||||
"""
|
||||
输出规格
|
||||
|
||||
用于 RENDER_SEGMENT_VIDEO 任务,定义视频输出参数。
|
||||
"""
|
||||
width: int = 1080
|
||||
height: int = 1920
|
||||
fps: int = 30
|
||||
bitrate: int = 4000000
|
||||
codec: str = "h264"
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict]) -> 'OutputSpec':
|
||||
"""从字典创建 OutputSpec"""
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
width=data.get('width', 1080),
|
||||
height=data.get('height', 1920),
|
||||
fps=data.get('fps', 30),
|
||||
bitrate=data.get('bitrate', 4000000),
|
||||
codec=data.get('codec', 'h264')
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioSpec:
|
||||
"""
|
||||
音频规格
|
||||
|
||||
用于 PREPARE_JOB_AUDIO 任务中的片段叠加音效。
|
||||
"""
|
||||
audio_url: Optional[str] = None
|
||||
volume: float = 1.0
|
||||
fade_in_ms: int = 10
|
||||
fade_out_ms: int = 10
|
||||
start_ms: int = 0
|
||||
delay_ms: int = 0
|
||||
loop_enable: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict]) -> Optional['AudioSpec']:
|
||||
"""从字典创建 AudioSpec"""
|
||||
if not data:
|
||||
return None
|
||||
return cls(
|
||||
audio_url=data.get('audioUrl'),
|
||||
volume=float(data.get('volume', 1.0)),
|
||||
fade_in_ms=int(data.get('fadeInMs', 10)),
|
||||
fade_out_ms=int(data.get('fadeOutMs', 10)),
|
||||
start_ms=int(data.get('startMs', 0)),
|
||||
delay_ms=int(data.get('delayMs', 0)),
|
||||
loop_enable=data.get('loopEnable', False)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioProfile:
|
||||
"""
|
||||
音频配置
|
||||
|
||||
用于 PREPARE_JOB_AUDIO 任务的全局音频参数。
|
||||
"""
|
||||
sample_rate: int = 48000
|
||||
channels: int = 2
|
||||
codec: str = "aac"
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict]) -> 'AudioProfile':
|
||||
"""从字典创建 AudioProfile"""
|
||||
if not data:
|
||||
return cls()
|
||||
return cls(
|
||||
sample_rate=data.get('sampleRate', 48000),
|
||||
channels=data.get('channels', 2),
|
||||
codec=data.get('codec', 'aac')
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
"""
|
||||
任务实体
|
||||
|
||||
表示一个待执行的渲染任务。
|
||||
"""
|
||||
task_id: str
|
||||
task_type: TaskType
|
||||
priority: int
|
||||
lease_expire_time: datetime
|
||||
payload: Dict[str, Any]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> 'Task':
|
||||
"""从 API 响应字典创建 Task"""
|
||||
lease_time_str = data.get('leaseExpireTime', '')
|
||||
|
||||
# 解析 ISO 8601 时间格式
|
||||
if lease_time_str:
|
||||
if lease_time_str.endswith('Z'):
|
||||
lease_time_str = lease_time_str[:-1] + '+00:00'
|
||||
try:
|
||||
lease_expire_time = datetime.fromisoformat(lease_time_str)
|
||||
except ValueError:
|
||||
# 解析失败时使用当前时间 + 5分钟
|
||||
lease_expire_time = datetime.now()
|
||||
else:
|
||||
lease_expire_time = datetime.now()
|
||||
|
||||
return cls(
|
||||
task_id=str(data['taskId']),
|
||||
task_type=TaskType(data['taskType']),
|
||||
priority=data.get('priority', 0),
|
||||
lease_expire_time=lease_expire_time,
|
||||
payload=data.get('payload', {})
|
||||
)
|
||||
|
||||
def get_job_id(self) -> str:
|
||||
"""获取作业 ID"""
|
||||
return str(self.payload.get('jobId', ''))
|
||||
|
||||
def get_segment_id(self) -> Optional[str]:
|
||||
"""获取片段 ID(如果有)"""
|
||||
segment_id = self.payload.get('segmentId')
|
||||
return str(segment_id) if segment_id else None
|
||||
|
||||
def get_plan_segment_index(self) -> int:
|
||||
"""获取计划片段索引"""
|
||||
return int(self.payload.get('planSegmentIndex', 0))
|
||||
|
||||
def get_duration_ms(self) -> int:
|
||||
"""获取时长(毫秒)"""
|
||||
return int(self.payload.get('durationMs', 5000))
|
||||
|
||||
def get_material_url(self) -> Optional[str]:
|
||||
"""获取素材 URL"""
|
||||
return self.payload.get('boundMaterialUrl') or self.payload.get('sourceRef')
|
||||
|
||||
def get_render_spec(self) -> RenderSpec:
|
||||
"""获取渲染规格"""
|
||||
return RenderSpec.from_dict(self.payload.get('renderSpec'))
|
||||
|
||||
def get_output_spec(self) -> OutputSpec:
|
||||
"""获取输出规格"""
|
||||
return OutputSpec.from_dict(self.payload.get('output'))
|
||||
|
||||
def get_bgm_url(self) -> Optional[str]:
|
||||
"""获取 BGM URL"""
|
||||
return self.payload.get('bgmUrl')
|
||||
|
||||
def get_total_duration_ms(self) -> int:
|
||||
"""获取总时长(毫秒)"""
|
||||
return int(self.payload.get('totalDurationMs', 0))
|
||||
|
||||
def get_segments(self) -> List[Dict]:
|
||||
"""获取片段列表"""
|
||||
return self.payload.get('segments', [])
|
||||
|
||||
def get_audio_profile(self) -> AudioProfile:
|
||||
"""获取音频配置"""
|
||||
return AudioProfile.from_dict(self.payload.get('audioProfile'))
|
||||
|
||||
def get_video_url(self) -> Optional[str]:
|
||||
"""获取视频 URL(用于 PACKAGE_SEGMENT_TS)"""
|
||||
return self.payload.get('videoUrl')
|
||||
|
||||
def get_audio_url(self) -> Optional[str]:
|
||||
"""获取音频 URL(用于 PACKAGE_SEGMENT_TS)"""
|
||||
return self.payload.get('audioUrl')
|
||||
|
||||
def get_start_time_ms(self) -> int:
|
||||
"""获取开始时间(毫秒)"""
|
||||
return int(self.payload.get('startTimeMs', 0))
|
||||
|
||||
def get_m3u8_url(self) -> Optional[str]:
|
||||
"""获取 m3u8 URL(用于 FINALIZE_MP4)"""
|
||||
return self.payload.get('m3u8Url')
|
||||
|
||||
def get_ts_list(self) -> List[str]:
|
||||
"""获取 TS 列表(用于 FINALIZE_MP4)"""
|
||||
return self.payload.get('tsList', [])
|
||||
Reference in New Issue
Block a user