""" 视频合成服务 支持多种剪辑模式和转场效果,包含完整的安全校验 """ import logging import os import subprocess import tempfile from dataclasses import dataclass from enum import Enum try: from enum import StrEnum except ImportError: class StrEnum(str, Enum): # type: ignore[no-redef] """Python 3.10 兼容的 StrEnum 回退实现。""" pass from pathlib import Path from typing import Optional from packages.domain.editing_mode import EditingMode logger = logging.getLogger(__name__) # ========== 安全常量 ========== # 允许的输出目录白名单(使用环境变量或系统临时目录,避免硬编码 /tmp) _VIDEO_OUTPUT_DIR = os.environ.get("VIDEO_OUTPUT_DIR", os.path.join(tempfile.gettempdir(), "video_output")) ALLOWED_OUTPUT_DIRS = [_VIDEO_OUTPUT_DIR, "/var/app/rendered"] # 允许的输入路径前缀白名单 ALLOWED_INPUT_PREFIXES = ("s3://", "oss://", "local://", "/var/storage/") # 允许的转场效果白名单 ALLOWED_TRANSITIONS = { "fade", "slideleft", "slideright", "dissolve", "wipeleft", "wiperight", "cut", "slideup", "slidedown", } # 转场效果映射 _XFADE_TRANSITION_MAP = { "fade": "fade", "slideleft": "slideleft", "slideright": "slideright", "dissolve": "dissolve", "wipeleft": "wipeleft", "wiperight": "wiperight", "cut": "cut", "slideup": "slideup", "slidedown": "slidedown", } class VideoComposeError(Exception): """视频合成服务异常""" pass class PIPPosition(StrEnum): """画中画位置枚举""" TOP_LEFT = "top_left" TOP_RIGHT = "top_right" BOTTOM_LEFT = "bottom_left" BOTTOM_RIGHT = "bottom_right" @dataclass class Clip: """视频片段""" asset_id: str # 资源ID,对应输入路径 start_time: float = 0.0 duration: float = 0.0 transition: str = "fade" # 转场效果 @dataclass class EditingModeConfig: """剪辑模式配置""" mode: EditingMode output_width: int = 1280 output_height: int = 720 output_fps: int = 25 pip_position: PIPPosition = PIPPosition.TOP_RIGHT pip_scale: float = 0.25 # 画中画占主画面的比例 transition_duration: float = 0.5 # 转场时长(秒) output_codec: str = "libx264" output_preset: str = "medium" output_crf: int = 23 class VideoComposeService: """视频合成服务""" def __init__(self, config: EditingModeConfig, work_dir: Optional[str] = None): """ 初始化视频合成服务 Args: config: 剪辑模式配置 work_dir: 工作目录,默认使用系统临时目录 """ self.config = config self.work_dir = work_dir or tempfile.gettempdir() self._ffmpeg_bin = "ffmpeg" self._ffprobe_bin = "ffprobe" def _validate_output_path(self, path: str) -> str: """ 校验输出路径是否在允许范围内 (P0 修复) 防止路径穿越攻击,如 /app/config/../../../etc/passwd Args: path: 用户提供的输出路径 Returns: 标准化后的绝对路径 Raises: ValueError: 路径不在允许范围内 """ abs_path = os.path.abspath(path) for allowed_dir in ALLOWED_OUTPUT_DIRS: allowed_abs = os.path.abspath(allowed_dir) if abs_path.startswith(allowed_abs): return abs_path raise ValueError(f"输出路径不在允许范围内: {path}") def _validate_input_path(self, path: str) -> bool: """ 校验输入路径格式是否合法 (P1-1 修复) Args: path: 输入文件路径 Returns: 是否合法 """ return any(path.startswith(prefix) for prefix in ALLOWED_INPUT_PREFIXES) def _validate_transition(self, transition: str) -> str: """ 校验转场效果是否在白名单内 (P1-2 修复) Args: transition: 转场效果名称 Returns: 安全的转场效果名称 """ if transition not in ALLOWED_TRANSITIONS: logger.warning(f"未知的转场效果 '{transition}',使用默认 'fade'") return "fade" return transition def _get_validated_transition(self, transition: str) -> str: """获取白名单校验后的转场效果名称""" return _XFADE_TRANSITION_MAP.get(self._validate_transition(transition), "fade") def compose(self, clips: list[Clip], output_path: Optional[str] = None) -> str: """ 合成视频 Args: clips: 视频片段列表,每个片段包含 asset_id 和转场配置 output_path: 输出文件路径 Returns: 输出文件路径 """ if not clips: raise ValueError("clips 不能为空") # P1-1: 校验所有输入路径 for clip in clips: if not self._validate_input_path(clip.asset_id): raise ValueError(f"不合法的输入路径: {clip.asset_id}") # 生成默认输出路径并校验 if output_path is None: output_path = self._generate_output_path() # P0: 校验输出路径 validated_output = self._validate_output_path(output_path) logger.info(f"合成视频,片段数: {len(clips)}, 输出: {validated_output}") # 获取输入路径列表 input_paths = [clip.asset_id for clip in clips] try: if self.config.mode == EditingMode.ONE_TAKE: return self._one_take(input_paths, validated_output, clips) elif self.config.mode == EditingMode.PIP: return self._pip(input_paths, validated_output) elif self.config.mode == EditingMode.VOICE_OVER: return self._voice_over(input_paths, validated_output) elif self.config.mode == EditingMode.VOICE_PIP: return self._voice_pip(input_paths, validated_output) else: raise ValueError(f"不支持的剪辑模式: {self.config.mode}") except Exception as e: logger.error(f"视频合成失败: {e}") raise VideoComposeError(f"视频合成失败: {e}") from e def _generate_output_path(self) -> str: """生成输出文件路径""" os.makedirs(self.work_dir, exist_ok=True) return os.path.join(self.work_dir, f"output_{self.config.mode}_{os.getpid()}.mp4") def _validate_inputs(self, video_paths: list[str], audio_path: Optional[str] = None) -> None: """验证输入文件存在""" for path in video_paths: if not os.path.exists(path): raise FileNotFoundError(f"视频文件不存在: {path}") if not os.path.getsize(path) > 0: raise ValueError(f"视频文件为空: {path}") if audio_path and not os.path.exists(audio_path): raise FileNotFoundError(f"音频文件不存在: {audio_path}") def _run_ffmpeg(self, command: list[str], capture_output: bool = True) -> tuple: """执行 FFmpeg 命令""" logger.debug(f"Running FFmpeg: {' '.join(command)}") try: result = subprocess.run( command, check=True, stdout=subprocess.PIPE if capture_output else None, stderr=subprocess.PIPE if capture_output else None, text=capture_output, ) return result.stdout or "", result.stderr or "" except subprocess.CalledProcessError as e: stderr = e.stderr.decode() if e.stderr else str(e) logger.error(f"FFmpeg error: {stderr}") raise RuntimeError(f"FFmpeg 执行失败: {stderr}") from e def _get_video_info(self, video_path: str) -> dict: """获取视频信息""" try: result = subprocess.run( [ self._ffprobe_bin, "-v", "error", "-show_entries", "stream=width,height,r_frame_rate,duration,codec_name", "-show_entries", "format=duration,size", "-of", "json", video_path, ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) import json data = json.loads(result.stdout) streams = data.get("streams", [{}]) video_stream = next((s for s in streams if s.get("codec_type") == "video"), streams[0] if streams else {}) fmt = data.get("format", {}) fps_str = video_stream.get("r_frame_rate", "25/1") fps_parts = fps_str.split("/") fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) == 2 else float(fps_parts[0]) return { "width": int(video_stream.get("width", 0)), "height": int(video_stream.get("height", 0)), "fps": fps, "duration": float(fmt.get("duration", 0)), "codec": video_stream.get("codec_name", "unknown"), "size": int(fmt.get("size", 0)), } except Exception as e: logger.warning(f"获取视频信息失败 {video_path}: {e}") return {"width": 0, "height": 0, "fps": 25, "duration": 0, "codec": "unknown", "size": 0} def _get_pip_position_offset( self, main_width: int, main_height: int, pip_width: int, pip_height: int ) -> tuple[int, int]: """获取画中画位置偏移量""" margin = 10 position_offsets = { PIPPosition.TOP_LEFT: (margin, margin), PIPPosition.TOP_RIGHT: (main_width - pip_width - margin, margin), PIPPosition.BOTTOM_LEFT: (margin, main_height - pip_height - margin), PIPPosition.BOTTOM_RIGHT: (main_width - pip_width - margin, main_height - pip_height - margin), } return position_offsets.get(self.config.pip_position, position_offsets[PIPPosition.TOP_RIGHT]) def _normalize_video(self, input_path: str, output_path: str) -> dict: """标准化视频格式""" command = [ self._ffmpeg_bin, "-y", "-i", input_path, "-r", str(self.config.output_fps), "-vf", f"scale={self.config.output_width}:{self.config.output_height}:force_original_aspect_ratio=decrease,pad={self.config.output_width}:{self.config.output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1", "-r", str(self.config.output_fps), "-c:v", self.config.output_codec, "-preset", self.config.output_preset, "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-an", output_path, ] self._run_ffmpeg(command) return self._get_video_info(output_path) def _one_take(self, video_paths: list[str], output_path: str, clips: list[Clip]) -> str: """一镜到底模式""" if len(video_paths) == 1: return self._normalize_video(video_paths[0], output_path) normalized_paths = [] for i, path in enumerate(video_paths): normalized = os.path.join(self.work_dir, f"normalized_{i}_{os.getpid()}.mp4") self._normalize_video(path, normalized) normalized_paths.append(normalized) durations = [self._get_video_info(p)["duration"] for p in normalized_paths] if len(normalized_paths) <= 5: output_path = self._one_take_with_xfade(normalized_paths, durations, output_path, clips) else: output_path = self._one_take_simple_concat(normalized_paths, output_path) for p in normalized_paths: try: if p != output_path: os.remove(p) except Exception as e: logger.warning( f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True ) return output_path def _one_take_with_xfade( self, normalized_paths: list[str], durations: list[float], output_path: str, clips: list[Clip] ) -> str: """使用 xfade 滤镜实现转场 (P1-2: 转场参数白名单校验)""" if len(normalized_paths) == 2: # 获取当前片段的转场效果并校验白名单 transition = "fade" if len(clips) > 1: transition = self._get_validated_transition(clips[1].transition) trans_duration = self.config.transition_duration offset1 = durations[0] - trans_duration / 2 command = [ self._ffmpeg_bin, "-y", "-i", normalized_paths[0], "-i", normalized_paths[1], "-filter_complex", f"[0:v][1:v]xfade=transition={transition}:duration={trans_duration}:offset={offset1}[v]", "-map", "[v]", "-c:v", self.config.output_codec, "-preset", self.config.output_preset, "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path, ] self._run_ffmpeg(command) return output_path else: return self._one_take_simple_concat(normalized_paths, output_path) def _one_take_simple_concat(self, normalized_paths: list[str], output_path: str) -> str: """使用 concat demuxer 简单拼接""" concat_file = os.path.join(self.work_dir, f"concat_list_{os.getpid()}.txt") with open(concat_file, "w") as f: for path in normalized_paths: f.write(f"file '{os.path.abspath(path)}'\n") command = [ self._ffmpeg_bin, "-y", "-f", "concat", "-safe", "0", "-i", concat_file, "-c", "copy", output_path, ] self._run_ffmpeg(command) try: os.remove(concat_file) except Exception as e: logger.warning( f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True ) return output_path def _pip(self, video_paths: list[str], output_path: str) -> str: """画中画模式""" if not video_paths: raise ValueError("No video paths provided") main_video = video_paths[0] main_normalized = os.path.join(self.work_dir, f"main_{os.getpid()}.mp4") main_info = self._normalize_video(main_video, main_normalized) if len(video_paths) == 1: os.rename(main_normalized, output_path) return output_path pip_width = int(self.config.output_width * self.config.pip_scale) pip_height = int(self.config.output_height * self.config.pip_scale) x_offset, y_offset = self._get_pip_position_offset( self.config.output_width, self.config.output_height, pip_width, pip_height ) pip_normalized = os.path.join(self.work_dir, f"pip_{os.getpid()}.mp4") pip_info = self._get_video_info(video_paths[1]) if pip_info["duration"] > main_info["duration"]: temp_pip = os.path.join(self.work_dir, f"pip_temp_{os.getpid()}.mp4") command = [ self._ffmpeg_bin, "-y", "-i", video_paths[1], "-t", str(main_info["duration"]), "-vf", f"scale={pip_width}:{pip_height}", "-c:v", self.config.output_codec, "-preset", self.config.output_preset, "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", temp_pip, ] self._run_ffmpeg(command) pip_normalized_input = temp_pip else: command = [ self._ffmpeg_bin, "-y", "-i", video_paths[1], "-vf", f"scale={pip_width}:{pip_height}", "-c:v", self.config.output_codec, "-preset", self.config.output_preset, "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", pip_normalized, ] self._run_ffmpeg(command) pip_normalized_input = pip_normalized if main_info["duration"] > pip_info["duration"]: looped_pip = os.path.join(self.work_dir, f"pip_looped_{os.getpid()}.mp4") command = [ self._ffmpeg_bin, "-y", "-stream_loop", "-1", "-i", pip_normalized_input, "-t", str(main_info["duration"]), "-vf", f"scale={pip_width}:{pip_height}", "-c:v", self.config.output_codec, "-preset", self.config.output_preset, "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", looped_pip, ] self._run_ffmpeg(command) pip_normalized_input = looped_pip command = [ self._ffmpeg_bin, "-y", "-i", main_normalized, "-i", pip_normalized_input, "-filter_complex", f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]", "-map", "[v]", "-c:v", self.config.output_codec, "-preset", self.config.output_preset, "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path, ] self._run_ffmpeg(command) for temp_file in [main_normalized, pip_normalized]: if temp_file and temp_file != output_path: try: os.remove(temp_file) except Exception as e: logger.warning( f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True ) return output_path def _voice_over(self, video_paths: list[str], audio_path: str, output_path: str) -> str: """口播模式""" if not audio_path: raise ValueError("audio_path is required for VOICE_OVER mode") if not video_paths: raise ValueError("No background video provided") audio_info = self._get_video_info(audio_path) audio_duration = audio_info["duration"] bg_normalized = os.path.join(self.work_dir, f"bg_{os.getpid()}.mp4") bg_info = self._normalize_video(video_paths[0], bg_normalized) if bg_info["duration"] < audio_duration: looped_bg = os.path.join(self.work_dir, f"bg_looped_{os.getpid()}.mp4") command = [ self._ffmpeg_bin, "-y", "-stream_loop", "-1", "-i", bg_normalized, "-t", str(audio_duration), "-vf", f"scale={self.config.output_width}:{self.config.output_height}", "-c:v", self.config.output_codec, "-preset", self.config.output_preset, "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", looped_bg, ] self._run_ffmpeg(command) bg_normalized = looped_bg elif bg_info["duration"] > audio_duration: temp_bg = os.path.join(self.work_dir, f"bg_trimmed_{os.getpid()}.mp4") command = [ self._ffmpeg_bin, "-y", "-i", bg_normalized, "-t", str(audio_duration), "-c:v", "copy", temp_bg, ] self._run_ffmpeg(command) bg_normalized = temp_bg blurred_bg = os.path.join(self.work_dir, f"bg_blurred_{os.getpid()}.mp4") command = [ self._ffmpeg_bin, "-y", "-i", bg_normalized, "-vf", f"boxblur=5:5,scale={self.config.output_width}:{self.config.output_height}", "-c:v", self.config.output_codec, "-preset", self.config.output_preset, "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", blurred_bg, ] self._run_ffmpeg(command) command = [ self._ffmpeg_bin, "-y", "-i", blurred_bg, "-i", audio_path, "-filter_complex", "[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=black@0.3:t=fill[v]", "-map", "[v]", "-map", "1:a", "-c:v", self.config.output_codec, "-preset", self.config.output_preset, "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", "-shortest", output_path, ] self._run_ffmpeg(command) for temp_file in [bg_normalized, blurred_bg]: try: if temp_file != output_path: os.remove(temp_file) except Exception as e: logger.warning( f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True ) return output_path def _voice_pip(self, video_paths: list[str], audio_path: Optional[str], output_path: str) -> str: """口播+画中画模式""" if not video_paths: raise ValueError("No video paths provided") if len(video_paths) == 1: return self._normalize_video(video_paths[0], output_path) voice_video = video_paths[0] bg_video = video_paths[1] if len(video_paths) > 1 else video_paths[0] voice_normalized = os.path.join(self.work_dir, f"voice_{os.getpid()}.mp4") voice_info = self._normalize_video(voice_video, voice_normalized) bg_normalized = os.path.join(self.work_dir, f"bg_{os.getpid()}.mp4") bg_info = self._normalize_video(bg_video, bg_normalized) final_duration = min(voice_info["duration"], bg_info["duration"]) pip_width = int(self.config.output_width * self.config.pip_scale) pip_height = int(self.config.output_height * self.config.pip_scale) x_offset, y_offset = self._get_pip_position_offset( self.config.output_width, self.config.output_height, pip_width, pip_height ) voice_adjusted = os.path.join(self.work_dir, f"voice_adj_{os.getpid()}.mp4") command = [ self._ffmpeg_bin, "-y", "-i", voice_normalized, "-t", str(final_duration), "-vf", f"scale={pip_width}:{pip_height}", "-c:v", self.config.output_codec, "-preset", self.config.output_preset, "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", voice_adjusted, ] self._run_ffmpeg(command) bg_adjusted = os.path.join(self.work_dir, f"bg_adj_{os.getpid()}.mp4") command = [ self._ffmpeg_bin, "-y", "-i", bg_normalized, "-t", str(final_duration), "-c:v", "copy", bg_adjusted, ] self._run_ffmpeg(command) if audio_path: command = [ self._ffmpeg_bin, "-y", "-i", bg_adjusted, "-i", voice_adjusted, "-i", audio_path, "-filter_complex", f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]", "-map", "[v]", "-map", "2:a", "-shortest", "-c:v", self.config.output_codec, "-preset", self.config.output_preset, "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path, ] else: command = [ self._ffmpeg_bin, "-y", "-i", bg_adjusted, "-i", voice_adjusted, "-filter_complex", f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]", "-map", "[v]", "-map", "1:a", "-shortest", "-c:v", self.config.output_codec, "-preset", self.config.output_preset, "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path, ] self._run_ffmpeg(command) for temp_file in [voice_normalized, voice_adjusted, bg_normalized, bg_adjusted]: try: if temp_file != output_path: os.remove(temp_file) except Exception as e: logger.warning( f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True ) return output_path def create_compose_service(mode: str, work_dir: Optional[str] = None, **kwargs) -> VideoComposeService: """便捷工厂函数:创建视频合成服务""" try: editing_mode = EditingMode(mode) except ValueError: raise ValueError(f"无效的剪辑模式: {mode}. 有效模式: {[m.value for m in EditingMode]}") config = EditingModeConfig( mode=editing_mode, output_width=kwargs.get("output_width", 1280), output_height=kwargs.get("output_height", 720), output_fps=kwargs.get("output_fps", 25), pip_position=PIPPosition(kwargs.get("pip_position", "top_right")), pip_scale=kwargs.get("pip_scale", 0.25), transition_duration=kwargs.get("transition_duration", 0.5), ) return VideoComposeService(config=config, work_dir=work_dir)