MoneyPrinterTurbo/app/services/video.py
2025-07-08 18:45:00 +08:00

508 lines
18 KiB
Python

import glob
import itertools
import os
import random
import shutil
import subprocess
import json
from typing import List
from loguru import logger
from app.models.schema import (
VideoAspect,
VideoParams,
VideoConcatMode,
VideoTransitionMode,
)
from app.utils import utils
def get_bgm_file(bgm_type: str, bgm_file: str):
if bgm_type == "random":
bgm_dir = utils.resource_dir("bgm")
if not os.path.exists(bgm_dir):
logger.warning(f"BGM directory not found: {bgm_dir}, trying assets/bgm")
bgm_dir = utils.resource_dir("assets/bgm")
if not os.path.exists(bgm_dir):
logger.warning(f"BGM directory not found: {bgm_dir}, skip adding BGM.")
return ""
bgm_files = glob.glob(os.path.join(bgm_dir, "*.mp3"))
if not bgm_files:
logger.warning(f"No BGM files found in {bgm_dir}, skip adding BGM.")
return ""
return random.choice(bgm_files)
if bgm_type == "local":
return bgm_file
return ""
def _run_ffmpeg_command(command: list):
try:
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, creationflags=subprocess.CREATE_NO_WINDOW)
stdout, stderr = process.communicate()
if process.returncode != 0:
logger.error(f"FFmpeg command failed with return code {process.returncode}")
logger.error(f"FFmpeg stderr: {stderr}")
return False
logger.debug(f"FFmpeg command successful: {' '.join(command)}")
logger.debug(f"FFmpeg stderr: {stderr}")
return True
except FileNotFoundError:
logger.error("ffmpeg or ffprobe not found. Please ensure they are installed and in your PATH.")
return False
except Exception as e:
logger.error(f"An error occurred while running ffmpeg: {e}")
return False
def get_video_duration(video_path: str) -> float:
"""Get the duration of a video using ffprobe."""
command = [
'ffprobe',
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
video_path
]
try:
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
return float(result.stdout)
except (subprocess.CalledProcessError, FileNotFoundError, ValueError) as e:
logger.error(f"Error getting duration for {video_path}: {e}")
return 0.0
def delete_files(files: List[str] | str):
if isinstance(files, str):
files = [files]
for file in files:
if os.path.exists(file):
try:
os.remove(file)
except Exception as e:
logger.warning(f"Failed to delete file {file}: {e}")
def create_video_clip_from_segments(segments: list, video_aspect: VideoAspect, output_path: str):
"""
Creates a video clip by concatenating pre-defined video segments.
Args:
segments (list): A list of VideoSegment objects, where each object represents a video segment
and contains 'path' and 'duration' attributes.
video_aspect (VideoAspect): The aspect ratio of the output video.
output_path (str): The path to save the output video clip.
Returns:
bool: True if the command was successful, False otherwise.
"""
if not segments:
logger.warning("No video segments provided, cannot create video clip.")
return False
w, h = video_aspect.to_resolution()
scale_filter = f"scale={w}:{h}:force_original_aspect_ratio=increase"
crop_filter = f"crop={w}:{h}"
sar_filter = "setsar=1"
fps_filter = "fps=60"
filter_complex_parts = []
concat_inputs = ""
input_files = []
input_mappings = {}
total_duration = sum(seg.duration for seg in segments)
for i, segment in enumerate(segments):
input_path = segment.path
duration = segment.duration
if input_path not in input_mappings:
input_mappings[input_path] = len(input_files)
input_files.append(input_path)
input_idx = input_mappings[input_path]
input_specifier = f"[{input_idx}:v]"
# Each segment is trimmed from the start of the source video.
trim_filter = f"{input_specifier}trim=start=1:duration={duration},setpts=PTS-STARTPTS"
processed_clip_name = f"[v{i}]"
filter_complex_parts.append(f"{trim_filter},{scale_filter},{crop_filter},{fps_filter}{processed_clip_name}")
concat_inputs += processed_clip_name
concat_filter = f"{concat_inputs}concat=n={len(segments)}:v=1:a=0,setsar=1[outv]"
filter_complex_parts.append(concat_filter)
command = [
"ffmpeg", "-y",
]
for file_path in input_files:
command.extend(["-i", file_path])
command.extend([
"-filter_complex",
";".join(filter_complex_parts),
"-map", "[outv]",
"-c:v", "libx264",
"-crf", "18",
"-an",
"-r", "60",
"-t", str(total_duration),
output_path
])
logger.info(f"Creating video clip for {output_path} with {len(segments)} segments (total duration: {total_duration:.2f}s) using ffmpeg.")
return _run_ffmpeg_command(command)
def concatenate_videos(video_paths: List[str], output_path: str, transition_mode: VideoTransitionMode = VideoTransitionMode.none):
logger.info(f"Concatenating {len(video_paths)} videos into {output_path} with transition: {transition_mode.name}")
if not video_paths:
logger.error("No video paths provided for concatenation.")
return False
if len(video_paths) == 1:
logger.info("Only one video, copying to output path.")
shutil.copy(video_paths[0], output_path)
return True
use_transition = transition_mode != VideoTransitionMode.none
# Nested function for fallback to simple concatenation
def fallback_concat():
logger.info("Using simple concat demuxer (no transitions).")
temp_file_path = os.path.join(os.path.dirname(output_path), "temp_video_list.txt")
try:
with open(temp_file_path, "w", encoding="utf-8") as f:
for video_path in video_paths:
# Normalize path for ffmpeg concat demuxer, which is sensitive to backslashes
normalized_path = video_path.replace('\\', '/')
f.write(f"file '{normalized_path}'\n")
command = [
"ffmpeg", "-y",
"-f", "concat",
"-safe", "0",
"-i", temp_file_path,
"-c", "copy",
output_path
]
if _run_ffmpeg_command(command):
logger.success(f"Successfully concatenated videos using concat demuxer: {output_path}")
return True
else:
logger.error("Failed to concatenate videos using concat demuxer.")
return False
finally:
delete_files(temp_file_path)
if not use_transition:
return fallback_concat()
# Proceed with transitions using xfade
logger.info("Using xfade for transitions.")
transition_duration = 0.5 # seconds
video_durations = [get_video_duration(p) for p in video_paths]
if any(d == 0.0 for d in video_durations):
logger.warning("Could not determine duration for all video clips, falling back to simple concatenation.")
return fallback_concat()
command = ["ffmpeg", "-y"]
for path in video_paths:
command.extend(["-i", path])
filter_chains = []
# Initial stream is [0:v]
last_stream_name = "[0:v]"
total_duration = 0
for i in range(1, len(video_paths)):
total_duration += video_durations[i-1]
offset = total_duration - transition_duration
input_stream_name = f"[{i}:v]"
output_stream_name = f"[v{i}]"
filter_chains.append(f"{last_stream_name}{input_stream_name}xfade=transition=fade:duration={transition_duration}:offset={offset}{output_stream_name}")
last_stream_name = output_stream_name
filter_complex = ";".join(filter_chains)
command.extend([
"-filter_complex", filter_complex,
"-map", last_stream_name,
"-c:v", "libx264",
"-movflags", "+faststart",
output_path
])
if _run_ffmpeg_command(command):
logger.success(f"Successfully concatenated videos with transitions: {output_path}")
return True
else:
logger.warning("FFmpeg command with transition failed, falling back to simple concatenation.")
return fallback_concat()
def add_audio_to_video(video_path: str, audio_path: str, output_path: str):
video_path = os.path.normpath(video_path)
audio_path = os.path.normpath(audio_path)
output_path = os.path.normpath(output_path)
# Check if the video already has an audio stream
has_audio_stream = False
try:
probe_command = [
"ffprobe", "-v", "error", "-select_streams", "a",
"-show_entries", "stream=codec_type", "-of", "csv=p=0", video_path
]
process = subprocess.run(probe_command, check=True, capture_output=True, text=True)
if process.stdout.strip():
has_audio_stream = True
except (subprocess.CalledProcessError, FileNotFoundError) as e:
logger.warning(f"Could not probe video for audio stream: {e}")
if has_audio_stream:
command = [
"ffmpeg",
"-y",
"-i", video_path,
"-i", audio_path,
"-c:v", "copy",
"-c:a", "aac",
"-map", "0:v:0",
"-map", "1:a:0",
"-shortest",
output_path,
]
else:
command = [
"ffmpeg",
"-y",
"-i", video_path,
"-i", audio_path,
"-c:v", "copy",
"-c:a", "aac",
"-map", "0:v:0",
"-map", "1:a:0",
output_path,
]
return _run_ffmpeg_command(command)
def add_bgm_to_video(video_path: str, bgm_path: str, bgm_volume: float, output_path: str) -> bool:
video_path = os.path.normpath(video_path)
bgm_path = os.path.normpath(bgm_path)
output_path = os.path.normpath(output_path)
"""
Mixes background music into a video's audio track using ffmpeg and outputs a new video file.
"""
logger.info(f"Mixing BGM '{bgm_path}' into video '{video_path}'")
video_duration = get_video_duration(video_path)
if video_duration == 0.0:
logger.error(f"Could not get duration of video {video_path}")
return False
command = [
"ffmpeg",
"-y",
"-i", video_path,
"-stream_loop", "-1",
"-i", bgm_path,
"-filter_complex", f"[0:a]volume=1.0[a0];[1:a]volume={bgm_volume}[a1];[a0][a1]amix=inputs=2:duration=first[a]",
"-map", "0:v",
"-map", "[a]",
"-c:v", "copy",
"-c:a", "aac",
"-t", str(video_duration),
"-shortest", # Add -shortest parameter here
output_path,
]
return _run_ffmpeg_command(command)
def add_subtitles_to_video(video_path: str, srt_path: str, font_name: str, font_size: int, text_fore_color: str, stroke_color: str, stroke_width: float, subtitle_position: str, custom_position: float, output_path: str):
video_path = os.path.normpath(video_path)
srt_path = os.path.normpath(srt_path)
output_path = os.path.normpath(output_path)
font_path = utils.get_font_path(font_name)
if not os.path.exists(font_path):
logger.error(f"Font '{font_name}' not found, using default.")
font_path = utils.get_font_path("MicrosoftYaHeiBold.ttc")
# This is the robust way to escape paths for ffmpeg filters on Windows
def escape_ffmpeg_path(path):
# Replace backslashes with forward slashes
escaped_path = path.replace('\\', '/')
# Escape colons
escaped_path = escaped_path.replace(':', '\\:')
return escaped_path
style_options = [
f"FontName='{os.path.basename(font_path)}'",
f"FontSize={font_size}",
f"PrimaryColour=&H{utils.rgb_to_bgr_hex(text_fore_color)}",
f"BorderStyle=1",
f"OutlineColour=&H{utils.rgb_to_bgr_hex(stroke_color)}",
f"Outline={stroke_width}",
f"Shadow=0",
f"MarginV=20"
]
if subtitle_position == 'bottom':
style_options.append("Alignment=2") # Bottom center
elif subtitle_position == 'top':
style_options.append("Alignment=8") # Top center
elif subtitle_position == 'center':
style_options.append("Alignment=5") # Middle center
else: # custom
style_options.append(f"Alignment=2,MarginV={int(custom_position)}")
style_string = ','.join(style_options)
# Correctly escape paths for ffmpeg's filtergraph
font_dir_escaped = escape_ffmpeg_path(os.path.dirname(font_path))
srt_path_escaped = escape_ffmpeg_path(srt_path)
subtitles_filter = f"subtitles='{srt_path_escaped}':force_style='{style_string}':fontsdir='{font_dir_escaped}'"
command = [
"ffmpeg", "-y",
"-i", video_path,
"-vf", subtitles_filter,
"-c:v", "libx264",
"-c:a", "aac",
"-b:a", "192k",
"-shortest",
"-vsync", "cfr",
output_path
]
return _run_ffmpeg_command(command)
# ... (rest of the code remains the same)
def process_scene_video(material_url: str, output_dir: str, target_duration: float, aspect_ratio: str = "16:9") -> str:
"""
下载单个视频素材,并将其处理(剪辑/循环)到目标时长,同时调整分辨率。
这是实现音画同步的关键步骤之一。
"""
try:
# 创建一个唯一的文件名
video_filename = os.path.join(output_dir, f"scene_{os.path.basename(material_url)}")
# 下载视频
response = requests.get(material_url, stream=True)
response.raise_for_status()
with open(video_filename, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
logger.info(f"Downloaded scene video to {video_filename}")
clip = VideoFileClip(video_filename)
# 如果原始视频时长短于目标时长,就循环视频
if clip.duration < target_duration:
clip = clip.loop(duration=target_duration)
# 如果原始视频时长长于目标时长,就剪辑视频
else:
clip = clip.subclip(0, target_duration)
# 调整分辨率和宽高比
if aspect_ratio == "16:9":
target_resolution = (1920, 1080)
else: # 9:16
target_resolution = (1080, 1920)
# 使用crop和resize确保画面内容不被拉伸
clip_resized = clip.resize(height=target_resolution[1]) if clip.size[0]/clip.size[1] < target_resolution[0]/target_resolution[1] else clip.resize(width=target_resolution[0])
clip_cropped = clip_resized.crop(x_center=clip_resized.size[0]/2, y_center=clip_resized.size[1]/2, width=target_resolution[0], height=target_resolution[1])
processed_filename = os.path.join(output_dir, f"processed_{os.path.basename(video_filename)}")
clip_cropped.write_videofile(processed_filename, codec="libx264", audio_codec="aac", fps=30, ffmpeg_params=['-pix_fmt', 'yuv420p'])
clip.close()
clip_cropped.close()
os.remove(video_filename) # 删除原始下载文件
logger.info(f"Processed scene video to {processed_filename}, duration: {target_duration}s")
return processed_filename
except Exception as e:
logger.error(f"Error processing scene video from {material_url}: {e}")
return None
def generate_video(
video_path: str,
audio_path: str,
subtitle_path: str,
output_file: str,
params: VideoParams,
) -> str:
"""
Generates the final video by adding background music and subtitles using FFmpeg.
Args:
video_path (str): Path to the source video file.
audio_path (str): Path to the background music file.
subtitle_path (str): Path to the subtitle file.
output_file (str): Path to save the final output video.
params (VideoParams): Video parameters including bgm_volume.
Returns:
str: The path to the final video if successful, otherwise an empty string.
"""
logger.info(f"Generating final video for {output_file}")
temp_dir = os.path.join(os.path.dirname(output_file), "temp_gen")
os.makedirs(temp_dir, exist_ok=True)
final_video_path = ""
try:
# Step 1: Add background music
logger.info("Step 1: Adding background music.")
video_with_bgm_path = os.path.join(temp_dir, f"bgm_{os.path.basename(video_path)}")
bgm_added_path = add_bgm_to_video_ffmpeg(
video_path=video_path,
bgm_path=audio_path,
output_path=video_with_bgm_path,
bgm_volume=params.bgm_volume
)
if not bgm_added_path:
logger.error("Failed to add background music. Aborting video generation.")
return ""
# Step 2: Add subtitles
logger.info("Step 2: Adding subtitles.")
subtitled_video_path = add_subtitles_to_video_ffmpeg(
video_path=bgm_added_path,
subtitles_path=subtitle_path,
output_path=output_file
)
if subtitled_video_path:
logger.success(f"Successfully generated final video: {output_file}")
final_video_path = output_file
else:
logger.error("Failed to add subtitles. Final video not created.")
finally:
# Clean up temporary directory
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return final_video_path