8935196fcd
Deploy / Staging E2E Tests (push) Has been skipped
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 138h4m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 138h4m39s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 138h4m39s
701 lines
24 KiB
Python
701 lines
24 KiB
Python
"""
|
|
视频剪辑模式处理器
|
|
支持四种剪辑模式:一镜到底、画中画、口播、口播+画中画
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
|
|
if sys.version_info >= (3, 11):
|
|
from enum import StrEnum
|
|
else:
|
|
from enum import Enum
|
|
|
|
class StrEnum(str, Enum):
|
|
pass
|
|
|
|
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# 从 domain 层导入 EditingMode,避免重复定义
|
|
from packages.domain.editing_mode import EditingMode
|
|
|
|
|
|
class PIPPosition(StrEnum):
|
|
"""画中画位置枚举"""
|
|
|
|
TOP_LEFT = "top_left"
|
|
TOP_RIGHT = "top_right"
|
|
BOTTOM_LEFT = "bottom_left"
|
|
BOTTOM_RIGHT = "bottom_right"
|
|
|
|
|
|
@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 EditingModeProcessor:
|
|
"""剪辑模式处理器"""
|
|
|
|
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 process(
|
|
self,
|
|
video_paths: list[str],
|
|
audio_path: Optional[str] = None,
|
|
output_path: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
根据模式处理视频,返回输出文件路径
|
|
|
|
Args:
|
|
video_paths: 视频素材路径列表
|
|
audio_path: 音频路径(用于口播模式)
|
|
output_path: 输出文件路径,默认自动生成
|
|
|
|
Returns:
|
|
输出文件路径
|
|
"""
|
|
if not video_paths:
|
|
raise ValueError("video_paths cannot be empty")
|
|
|
|
self._validate_inputs(video_paths, audio_path)
|
|
|
|
if output_path is None:
|
|
output_path = self._generate_output_path()
|
|
|
|
logger.info(f"Processing videos with mode: {self.config.mode}, count: {len(video_paths)}")
|
|
|
|
try:
|
|
if self.config.mode == EditingMode.ONE_TAKE:
|
|
return self._one_take(video_paths, output_path)
|
|
elif self.config.mode == EditingMode.PIP:
|
|
return self._pip(video_paths, output_path)
|
|
elif self.config.mode == EditingMode.VOICE_OVER:
|
|
return self._voice_over(video_paths, audio_path, output_path)
|
|
elif self.config.mode == EditingMode.VOICE_PIP:
|
|
return self._voice_pip(video_paths, audio_path, output_path)
|
|
else:
|
|
raise ValueError(f"Unsupported editing mode: {self.config.mode}")
|
|
except Exception as e:
|
|
logger.error(f"Error processing videos: {e}")
|
|
raise
|
|
|
|
def _validate_inputs(self, video_paths: list[str], audio_path: Optional[str]) -> None:
|
|
"""验证输入文件"""
|
|
for path in video_paths:
|
|
if not os.path.exists(path):
|
|
raise FileNotFoundError(f"Video file not found: {path}")
|
|
if not os.path.getsize(path) > 0:
|
|
raise ValueError(f"Video file is empty: {path}")
|
|
|
|
if audio_path and not os.path.exists(audio_path):
|
|
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
|
|
|
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 _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 execution failed: {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"Failed to get video info for {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) -> 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)
|
|
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/editing_modes.py: {e}", exc_info=True)
|
|
|
|
return output_path
|
|
|
|
def _one_take_with_xfade(self, normalized_paths: list[str], durations: list[float], output_path: str) -> str:
|
|
"""使用 xfade 滤镜实现转场"""
|
|
if len(normalized_paths) == 2:
|
|
transition = self.config.transition_duration
|
|
offset1 = durations[0] - transition / 2
|
|
|
|
command = [
|
|
self._ffmpeg_bin,
|
|
"-y",
|
|
"-i",
|
|
normalized_paths[0],
|
|
"-i",
|
|
normalized_paths[1],
|
|
"-filter_complex",
|
|
f"[0:v][1:v]xfade=transition=fade:duration={transition}: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/editing_modes.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/editing_modes.py: {e}", exc_info=True
|
|
)
|
|
|
|
return output_path
|
|
|
|
def _voice_over(self, video_paths: list[str], audio_path: Optional[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/editing_modes.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/editing_modes.py: {e}", exc_info=True)
|
|
|
|
return output_path
|
|
|
|
|
|
def create_processor(mode: str, work_dir: Optional[str] = None, **kwargs) -> EditingModeProcessor:
|
|
"""便捷工厂函数:创建剪辑模式处理器"""
|
|
try:
|
|
editing_mode = EditingMode(mode)
|
|
except ValueError:
|
|
raise ValueError(f"Invalid editing mode: {mode}. Valid modes: {[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 EditingModeProcessor(config=config, work_dir=work_dir)
|