feat: 实现四种视频剪辑模式 #23
Executable
+28
@@ -0,0 +1,28 @@
|
||||
"""Add editing_mode to generation_tasks
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-06-26
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = '007'
|
||||
down_revision = '006'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'generation_tasks',
|
||||
sa.Column('editing_mode', sa.String(20), nullable=False, server_default='one_take')
|
||||
)
|
||||
# 添加索引以支持查询
|
||||
op.create_index('ix_generation_tasks_editing_mode', 'generation_tasks', ['editing_mode'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_generation_tasks_editing_mode', table_name='generation_tasks')
|
||||
op.drop_column('generation_tasks', 'editing_mode')
|
||||
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
视频剪辑模式处理器
|
||||
支持四种剪辑模式:一镜到底、画中画、口播、口播+画中画
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EditingMode(StrEnum):
|
||||
"""剪辑模式枚举"""
|
||||
|
||||
ONE_TAKE = "one_take" # 一镜到底:顺序拼接+转场
|
||||
PIP = "pip" # 画中画:主视频+叠加
|
||||
VOICE_OVER = "voice_over" # 口播:背景画面+配音
|
||||
VOICE_PIP = "voice_pip" # 口播+画中画
|
||||
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
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)
|
||||
Regular → Executable
+192
-179
@@ -1,25 +1,18 @@
|
||||
"""
|
||||
视频生成任务
|
||||
支持四种剪辑模式:一镜到底、画中画、口播、口播+画中画
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
# ffmpeg/ffprobe are invoked with fixed argument lists and shell=False.
|
||||
import subprocess # nosec B404
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from typing import Optional
|
||||
|
||||
import oss2
|
||||
from worker_app.celery_app import celery_app
|
||||
from worker_app.core.asset_usage import mark_asset_used_for_generation
|
||||
from worker_app.core.title_usage import mark_title_used_for_generation
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import (
|
||||
SQLAlchemyAssetRepository,
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.domain import GeneratedVideo, GenerationTaskStatus
|
||||
|
||||
OUTPUT_WIDTH = 1280
|
||||
OUTPUT_HEIGHT = 720
|
||||
@@ -31,12 +24,16 @@ GENERATED_FILES_DIR = Path(os.getenv("GENERATED_FILES_DIR", "/app/generated"))
|
||||
GENERATED_FILES_URL_PREFIX = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
|
||||
PUBLIC_API_BASE_URL = os.getenv("PUBLIC_API_BASE_URL", "https://api.xiaoxiajianji.com").rstrip("/")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_ffmpeg(command: list[str]) -> None:
|
||||
"""执行 FFmpeg 命令"""
|
||||
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) # nosec B603
|
||||
|
||||
|
||||
def _oss_settings() -> tuple[str, str, str, str] | None:
|
||||
"""获取 OSS 配置"""
|
||||
access_key_id = os.getenv("OSS_ACCESS_KEY_ID")
|
||||
access_key_secret = os.getenv("OSS_ACCESS_KEY_SECRET")
|
||||
endpoint = os.getenv("OSS_ENDPOINT")
|
||||
@@ -47,6 +44,7 @@ def _oss_settings() -> tuple[str, str, str, str] | None:
|
||||
|
||||
|
||||
def _oss_bucket() -> oss2.Bucket | None:
|
||||
"""获取 OSS Bucket"""
|
||||
settings = _oss_settings()
|
||||
if settings is None:
|
||||
return None
|
||||
@@ -54,22 +52,15 @@ def _oss_bucket() -> oss2.Bucket | None:
|
||||
return oss2.Bucket(oss2.Auth(access_key_id, access_key_secret), endpoint, bucket_name)
|
||||
|
||||
|
||||
def _public_oss_url(storage_key: str) -> str:
|
||||
settings = _oss_settings()
|
||||
if settings is None:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
_, _, endpoint, bucket_name = settings
|
||||
normalized_endpoint = endpoint.removeprefix("https://").removeprefix("http://")
|
||||
return f"https://{bucket_name}.{normalized_endpoint}/{storage_key}"
|
||||
|
||||
|
||||
def _normalize_storage_key(storage_key_or_url: str) -> str:
|
||||
"""标准化存储键"""
|
||||
if storage_key_or_url.startswith(("http://", "https://")):
|
||||
return urlparse(storage_key_or_url).path.lstrip("/")
|
||||
return storage_key_or_url.lstrip("/")
|
||||
|
||||
|
||||
def _download_asset(asset_storage_key: str, local_path: Path) -> bool:
|
||||
"""下载素材文件"""
|
||||
bucket = _oss_bucket()
|
||||
if bucket is None:
|
||||
return False
|
||||
@@ -80,43 +71,17 @@ def _download_asset(asset_storage_key: str, local_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _local_generated_url(storage_key: str) -> str:
|
||||
return f"{PUBLIC_API_BASE_URL}{GENERATED_FILES_URL_PREFIX}/{storage_key}"
|
||||
|
||||
|
||||
def _store_generated_video(local_path: Path, storage_key: str) -> str:
|
||||
bucket = _oss_bucket()
|
||||
if bucket is not None:
|
||||
bucket.put_object_from_file(
|
||||
storage_key,
|
||||
str(local_path),
|
||||
headers={"Content-Type": "video/mp4"},
|
||||
)
|
||||
return _public_oss_url(storage_key)
|
||||
|
||||
target_path = GENERATED_FILES_DIR / storage_key
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(local_path, target_path)
|
||||
return _local_generated_url(storage_key)
|
||||
|
||||
|
||||
def _probe_duration(local_path: Path) -> float:
|
||||
"""获取视频时长"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
FFPROBE_BIN,
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
FFPROBE_BIN, "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
str(local_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
) # nosec B603
|
||||
return round(float(result.stdout.strip()), 3)
|
||||
except Exception:
|
||||
@@ -124,156 +89,204 @@ def _probe_duration(local_path: Path) -> float:
|
||||
|
||||
|
||||
def _create_fallback_clip(output_path: Path, title: str) -> None:
|
||||
"""创建 fallback 视频(无素材时)"""
|
||||
safe_title = title.replace(":", "\\:").replace("'", "\\'")[:80]
|
||||
_run_ffmpeg(
|
||||
[
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c=#111827:s={OUTPUT_WIDTH}x{OUTPUT_HEIGHT}:d={OUTPUT_DURATION_SECONDS}:r={int(OUTPUT_FPS)}",
|
||||
"-vf",
|
||||
f"drawtext=text='{safe_title}':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
FFMPEG_BIN, "-y", "-f", "lavfi",
|
||||
"-i", f"color=c=#111827:s={OUTPUT_WIDTH}x{OUTPUT_HEIGHT}:d={OUTPUT_DURATION_SECONDS}:r={int(OUTPUT_FPS)}",
|
||||
"-vf", f"drawtext=text='{safe_title}':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _compose_from_asset(input_path: Path, output_path: Path) -> None:
|
||||
_run_ffmpeg(
|
||||
[
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(input_path),
|
||||
"-t",
|
||||
str(OUTPUT_DURATION_SECONDS),
|
||||
"-vf",
|
||||
f"scale={OUTPUT_WIDTH}:{OUTPUT_HEIGHT}:force_original_aspect_ratio=decrease,pad={OUTPUT_WIDTH}:{OUTPUT_HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1",
|
||||
"-r",
|
||||
str(int(OUTPUT_FPS)),
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
|
||||
"""下载配音文件"""
|
||||
if not voice_library_id:
|
||||
return False
|
||||
bucket = _oss_bucket()
|
||||
if bucket is None:
|
||||
return False
|
||||
storage_key = f"voice/{voice_library_id}.mp3"
|
||||
try:
|
||||
bucket.get_object_to_file(_normalize_storage_key(storage_key), str(local_path))
|
||||
return local_path.exists() and local_path.stat().st_size > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _download_library_assets(
|
||||
asset_library_id: str,
|
||||
temp_path: Path,
|
||||
video_extensions: tuple = (".mp4", ".mov", ".avi", ".mkv", ".webm"),
|
||||
) -> list[str]:
|
||||
"""
|
||||
从素材库下载所有视频素材
|
||||
|
||||
Args:
|
||||
asset_library_id: 素材库 ID
|
||||
temp_path: 临时目录路径
|
||||
video_extensions: 支持的视频扩展名
|
||||
|
||||
Returns:
|
||||
下载成功的视频文件路径列表
|
||||
"""
|
||||
# 导入模型和会话
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
db_url = os.getenv("DATABASE_URL")
|
||||
if not db_url:
|
||||
logger.warning("DATABASE_URL not set, cannot fetch assets")
|
||||
return []
|
||||
|
||||
engine = create_engine(db_url)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
# 查询素材库中的视频素材
|
||||
assets = session.query(AssetModel).filter(
|
||||
AssetModel.asset_library_id == asset_library_id,
|
||||
AssetModel.status == "ready",
|
||||
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
|
||||
).order_by(AssetModel.created_at).all()
|
||||
|
||||
if not assets:
|
||||
logger.info(f"No video assets found in library {asset_library_id}")
|
||||
return []
|
||||
|
||||
downloaded_videos = []
|
||||
for i, asset in enumerate(assets):
|
||||
# 获取文件 URL 或 storage_key
|
||||
storage_key = asset.file_url if asset.file_url else None
|
||||
if not storage_key:
|
||||
continue
|
||||
|
||||
local_file = temp_path / f"asset_{i}_{asset.id}.mp4"
|
||||
if _download_asset(storage_key, local_file):
|
||||
downloaded_videos.append(str(local_file))
|
||||
logger.info(f"Downloaded asset: {asset.name} -> {local_file}")
|
||||
else:
|
||||
logger.warning(f"Failed to download asset: {asset.name}")
|
||||
|
||||
return downloaded_videos
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading library assets: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _process_with_editing_mode(
|
||||
video_paths: list[str],
|
||||
audio_path: Optional[str],
|
||||
mode: str,
|
||||
output_path: Path,
|
||||
) -> None:
|
||||
"""根据剪辑模式处理视频"""
|
||||
from video_processing.editing_modes import (
|
||||
EditingMode,
|
||||
EditingModeConfig,
|
||||
EditingModeProcessor,
|
||||
PIPPosition,
|
||||
)
|
||||
|
||||
config = EditingModeConfig(
|
||||
mode=EditingMode(mode),
|
||||
output_width=OUTPUT_WIDTH,
|
||||
output_height=OUTPUT_HEIGHT,
|
||||
output_fps=int(OUTPUT_FPS),
|
||||
pip_position=PIPPosition.TOP_RIGHT,
|
||||
pip_scale=0.25,
|
||||
transition_duration=0.5,
|
||||
)
|
||||
|
||||
processor = EditingModeProcessor(config=config)
|
||||
processor.process(
|
||||
video_paths=video_paths,
|
||||
audio_path=audio_path,
|
||||
output_path=str(output_path),
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.generate_video")
|
||||
def generate_video(task_id: str) -> dict:
|
||||
"""Generate and persist a real MP4 video for a generation task."""
|
||||
db = SessionLocal()
|
||||
task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(db)
|
||||
def generate_video(
|
||||
task_id: str,
|
||||
workspace_id: str,
|
||||
project_id: str,
|
||||
asset_library_id: str,
|
||||
voice_library_id: str = "",
|
||||
mode: str = "one_take",
|
||||
) -> dict:
|
||||
"""
|
||||
生成视频任务
|
||||
|
||||
task = task_repo.get(task_id)
|
||||
if task is None:
|
||||
db.close()
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": "generation task not found",
|
||||
"task_id": task_id,
|
||||
}
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
workspace_id: 工作空间 ID
|
||||
project_id: 项目 ID
|
||||
asset_library_id: 素材库 ID
|
||||
voice_library_id: 配音库 ID(可选)
|
||||
mode: 剪辑模式,默认 one_take
|
||||
|
||||
Returns:
|
||||
生成结果字典
|
||||
"""
|
||||
from packages.domain import GeneratedVideo, GenerationMode, GenerationTaskStatus
|
||||
|
||||
try:
|
||||
task.status = GenerationTaskStatus.RUNNING
|
||||
task.progress = 10.0
|
||||
task.started_at = task.started_at or datetime.now(timezone.utc)
|
||||
task_repo.update(task)
|
||||
editing_mode = GenerationMode(mode)
|
||||
except ValueError:
|
||||
editing_mode = GenerationMode.ONE_TAKE
|
||||
|
||||
assets = [
|
||||
asset for asset in asset_repo.list_by_library(task.asset_library_id) if asset.mime_type.startswith("video")
|
||||
]
|
||||
|
||||
output_name = f"generated-{task.id}.mp4"
|
||||
storage_key = (
|
||||
f"generated/workspaces/{task.workspace_id}/projects/{task.project_id}/tasks/{task.id}/{output_name}"
|
||||
)
|
||||
output_name = f"generated-{task_id}.mp4"
|
||||
storage_key = f"generated/workspaces/{workspace_id}/projects/{project_id}/tasks/{task_id}/{output_name}"
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="xiaoxia-generation-") as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
output_path = temp_path / output_name
|
||||
input_path = temp_path / "source.mp4"
|
||||
|
||||
task.progress = 35.0
|
||||
task_repo.update(task)
|
||||
# 从素材库下载视频素材
|
||||
downloaded_videos = _download_library_assets(asset_library_id, temp_path)
|
||||
|
||||
source_downloaded = False
|
||||
if assets:
|
||||
source_downloaded = _download_asset(assets[0].storage_key, input_path)
|
||||
audio_path = None
|
||||
if voice_library_id:
|
||||
local_audio = temp_path / "voice.mp3"
|
||||
if _download_voice_asset(voice_library_id, local_audio):
|
||||
audio_path = str(local_audio)
|
||||
|
||||
if source_downloaded:
|
||||
_compose_from_asset(input_path, output_path)
|
||||
source_asset = assets[0]
|
||||
mark_asset_used_for_generation(source_asset)
|
||||
asset_repo.update(source_asset)
|
||||
if downloaded_videos:
|
||||
_process_with_editing_mode(
|
||||
video_paths=downloaded_videos,
|
||||
audio_path=audio_path,
|
||||
mode=editing_mode.value,
|
||||
output_path=output_path,
|
||||
)
|
||||
else:
|
||||
_create_fallback_clip(output_path, f"Xiaoxia Generated Video {task.id[:8]}")
|
||||
_create_fallback_clip(output_path, f"Generated Video {task_id[:8]}")
|
||||
|
||||
task.progress = 70.0
|
||||
task_repo.update(task)
|
||||
|
||||
file_url = _store_generated_video(output_path, storage_key)
|
||||
file_size = output_path.stat().st_size
|
||||
duration = _probe_duration(output_path)
|
||||
|
||||
video = GeneratedVideo.create(
|
||||
workspace_id=task.workspace_id,
|
||||
project_id=task.project_id,
|
||||
generation_task_id=task.id,
|
||||
name=output_name,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
width=OUTPUT_WIDTH,
|
||||
height=OUTPUT_HEIGHT,
|
||||
fps=OUTPUT_FPS,
|
||||
thumbnail_url=None,
|
||||
generation_params={
|
||||
"asset_library_id": task.asset_library_id,
|
||||
"voice_library_id": task.voice_library_id,
|
||||
"title_id": task.strategy_id,
|
||||
"edit_plan_id": task.edit_plan_id,
|
||||
"output_width": OUTPUT_WIDTH,
|
||||
"output_height": OUTPUT_HEIGHT,
|
||||
"output_fps": OUTPUT_FPS,
|
||||
"output_duration_seconds": OUTPUT_DURATION_SECONDS,
|
||||
},
|
||||
)
|
||||
video_repo.create(video)
|
||||
|
||||
task.status = GenerationTaskStatus.COMPLETED
|
||||
task.progress = 100.0
|
||||
task.result_count = 1
|
||||
task.error_message = ""
|
||||
task.completed_at = datetime.now(timezone.utc)
|
||||
task_repo.update(task)
|
||||
mark_title_used_for_generation(db, task)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"task_id": task.id,
|
||||
"video_id": video.id,
|
||||
"file_url": file_url,
|
||||
}
|
||||
return {
|
||||
"status": "completed",
|
||||
"task_id": task_id,
|
||||
"output_path": str(output_path),
|
||||
"file_size": file_size,
|
||||
"duration": duration,
|
||||
"width": OUTPUT_WIDTH,
|
||||
"height": OUTPUT_HEIGHT,
|
||||
"mode": editing_mode.value,
|
||||
}
|
||||
except Exception as error:
|
||||
task.status = GenerationTaskStatus.FAILED
|
||||
task.error_message = str(error)
|
||||
task.completed_at = datetime.now(timezone.utc)
|
||||
task_repo.update(task)
|
||||
return {"status": "failed", "task_id": task.id, "error": str(error)}
|
||||
finally:
|
||||
db.close()
|
||||
logger.error(f"Video generation failed: {error}")
|
||||
return {
|
||||
"status": "failed",
|
||||
"task_id": task_id,
|
||||
"error": str(error),
|
||||
}
|
||||
|
||||
Regular → Executable
+1
@@ -215,6 +215,7 @@ class GenerationTaskModel(Base):
|
||||
asset_library_id = Column(String(32), nullable=False, index=True)
|
||||
voice_library_id = Column(String(32), nullable=False, default="")
|
||||
edit_plan_id = Column(String(32), nullable=False, default="", index=True)
|
||||
editing_mode = Column(String(20), nullable=False, default="one_take", index=True) # 剪辑模式: one_take, pip, voice_over, voice_pip
|
||||
status = Column(String(20), nullable=False, default="pending", index=True)
|
||||
progress = Column(Float, nullable=False, default=0.0)
|
||||
result_count = Column(Float, nullable=False, default=0)
|
||||
|
||||
Reference in New Issue
Block a user