# -*- coding: utf-8 -*- """ TS 分片封装处理器 处理 PACKAGE_SEGMENT_TS 任务,将视频片段和对应时间区间的音频封装为 TS 分片。 """ import os import logging from typing import List from handlers.base import BaseHandler from domain.task import Task, TaskType from domain.result import TaskResult, ErrorCode logger = logging.getLogger(__name__) class PackageSegmentTsHandler(BaseHandler): """ TS 分片封装处理器 职责: - 下载视频片段 - 下载全局音频 - 截取对应时间区间的音频 - 封装为 TS 分片 - 上传 TS 产物 关键约束: - TS 必须包含音视频同轨 - 使用 output_ts_offset 保证时间戳连续 - 输出 extinfDurationSec 供 m3u8 使用 """ def get_supported_type(self) -> TaskType: return TaskType.PACKAGE_SEGMENT_TS def handle(self, task: Task) -> TaskResult: """处理 TS 封装任务""" work_dir = self.create_work_dir(task.task_id) try: # 解析参数 video_url = task.get_video_url() audio_url = task.get_audio_url() start_time_ms = task.get_start_time_ms() duration_ms = task.get_duration_ms() if not video_url: return TaskResult.fail( ErrorCode.E_SPEC_INVALID, "Missing videoUrl" ) if not audio_url: return TaskResult.fail( ErrorCode.E_SPEC_INVALID, "Missing audioUrl" ) # 计算时间参数 start_sec = start_time_ms / 1000.0 duration_sec = duration_ms / 1000.0 # 1. 下载视频片段 video_file = os.path.join(work_dir, 'video.mp4') if not self.download_file(video_url, video_file): return TaskResult.fail( ErrorCode.E_INPUT_UNAVAILABLE, f"Failed to download video: {video_url}" ) # 2. 下载全局音频 audio_file = os.path.join(work_dir, 'audio.aac') if not self.download_file(audio_url, audio_file): return TaskResult.fail( ErrorCode.E_INPUT_UNAVAILABLE, f"Failed to download audio: {audio_url}" ) # 3. 构建 TS 封装命令 output_file = os.path.join(work_dir, 'segment.ts') cmd = self._build_command( video_file=video_file, audio_file=audio_file, output_file=output_file, start_sec=start_sec, duration_sec=duration_sec ) # 4. 执行 FFmpeg if not self.run_ffmpeg(cmd, task.task_id): return TaskResult.fail( ErrorCode.E_FFMPEG_FAILED, "TS packaging failed" ) # 5. 验证输出文件 if not self.ensure_file_exists(output_file, min_size=1024): return TaskResult.fail( ErrorCode.E_FFMPEG_FAILED, "TS output file is missing or too small" ) # 6. 获取实际时长(用于 EXTINF) actual_duration = self.probe_duration(output_file) extinf_duration = actual_duration if actual_duration else duration_sec # 7. 上传产物 ts_url = self.upload_file(task.task_id, 'ts', output_file) if not ts_url: return TaskResult.fail( ErrorCode.E_UPLOAD_FAILED, "Failed to upload TS" ) return TaskResult.ok({ 'tsUrl': ts_url, 'extinfDurationSec': extinf_duration }) except Exception as e: logger.error(f"[task:{task.task_id}] Unexpected error: {e}", exc_info=True) return TaskResult.fail(ErrorCode.E_UNKNOWN, str(e)) finally: self.cleanup_work_dir(work_dir) def _build_command( self, video_file: str, audio_file: str, output_file: str, start_sec: float, duration_sec: float ) -> List[str]: """ 构建 TS 封装命令 Args: video_file: 视频文件路径 audio_file: 音频文件路径 output_file: 输出文件路径 start_sec: 开始时间(秒) duration_sec: 时长(秒) Returns: FFmpeg 命令参数列表 """ cmd = [ 'ffmpeg', '-y', '-hide_banner', # 视频输入 '-i', video_file, # 音频输入(从 start_sec 开始截取 duration_sec) '-ss', str(start_sec), '-t', str(duration_sec), '-i', audio_file, # 映射流 '-map', '0:v:0', # 使用第一个输入的视频流 '-map', '1:a:0', # 使用第二个输入的音频流 # 复制编码(不重新编码) '-c:v', 'copy', '-c:a', 'copy', # 关键:时间戳偏移,保证整体连续 '-output_ts_offset', str(start_sec), # 复用参数 '-muxdelay', '0', '-muxpreload', '0', # 输出格式 '-f', 'mpegts', output_file ] return cmd