308 lines
9.6 KiB
Python
308 lines
9.6 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
视频处理模块
|
||
|
||
包含视频剪辑、合并、淡入淡出等操作
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import logging
|
||
from .constants import FFMPEG_CMD, FFPROBE_CMD, DEFAULT_VIDEO_PARAMS, SUBTITLE_STYLE
|
||
from .utils import run_cmd, ensure_dir, get_clip_num
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def get_video_info(video_path):
|
||
"""
|
||
获取视频信息
|
||
|
||
Args:
|
||
video_path: 视频路径
|
||
|
||
Returns:
|
||
视频信息字典
|
||
"""
|
||
cmd = f'"{FFPROBE_CMD}" -v error -show_entries format=duration,size -of json "{video_path}"'
|
||
import subprocess
|
||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, encoding='utf-8', errors='ignore')
|
||
|
||
try:
|
||
import json as json_mod
|
||
return json_mod.loads(result.stdout)
|
||
except:
|
||
return {}
|
||
|
||
|
||
def extract_clip(video_src, start, end, output_path, fade_duration=1):
|
||
"""
|
||
从视频提取片段
|
||
|
||
Args:
|
||
video_src: 源视频路径
|
||
start: 开始时间(秒)
|
||
end: 结束时间(秒)
|
||
output_path: 输出路径
|
||
fade_duration: 淡入淡出时长
|
||
|
||
Returns:
|
||
True if success
|
||
"""
|
||
duration = end - start
|
||
|
||
# 使用掐头去尾精确控制
|
||
cmd = f'"{FFMPEG_CMD}" -y -ss {start} -i "{video_src}" -t {duration} '
|
||
|
||
if fade_duration > 0:
|
||
# 添加淡入淡出
|
||
cmd += f'-vf "fade=t=in:st=0:d={fade_duration},fade=t=out:st={duration-fade_duration}:d={fade_duration}" '
|
||
|
||
cmd += f'-c:v libx264 -crf 20 -c:a aac -y "{output_path}"'
|
||
|
||
success = run_cmd(cmd)
|
||
if success:
|
||
logger.info(f"Extracted clip: {output_path}")
|
||
else:
|
||
logger.error(f"Failed to extract clip: {output_path}")
|
||
|
||
return success
|
||
|
||
|
||
def merge_clips(clip_paths, output_path, inter_dir=None):
|
||
"""
|
||
合并多个视频片段
|
||
|
||
Args:
|
||
clip_paths: 片段路径列表
|
||
output_path: 输出路径
|
||
inter_dir: 中间目录(用于保存concat list)
|
||
|
||
Returns:
|
||
True if success
|
||
"""
|
||
if not clip_paths:
|
||
logger.error("No clips to merge")
|
||
return False
|
||
|
||
# 按编号排序
|
||
sorted_clips = sorted(clip_paths, key=lambda p: get_clip_num(p))
|
||
|
||
# 创建concat list
|
||
if inter_dir:
|
||
list_path = os.path.join(inter_dir, "concat_list.txt")
|
||
else:
|
||
list_path = os.path.join(os.path.dirname(output_path), "concat_list.txt")
|
||
|
||
with open(list_path, 'w', encoding='utf-8') as f:
|
||
for p in sorted_clips:
|
||
clip_num = get_clip_num(p)
|
||
# 验证clip对应的json存在
|
||
if inter_dir:
|
||
json_path = os.path.join(inter_dir, f"clip{clip_num}.json")
|
||
if not os.path.exists(json_path):
|
||
logger.warning(f"Skipping clip{clip_num} - no JSON found")
|
||
continue
|
||
f.write(f"file '{p}'\n")
|
||
|
||
# 合并
|
||
cmd = f'"{FFMPEG_CMD}" -y -f concat -safe 0 -i "{list_path}" -c copy -y "{output_path}"'
|
||
success = run_cmd(cmd)
|
||
|
||
if success:
|
||
logger.info(f"Merged {len(sorted_clips)} clips -> {output_path}")
|
||
else:
|
||
logger.error(f"Failed to merge clips")
|
||
|
||
return success
|
||
|
||
|
||
def burn_subtitles(video_path, srt_path, output_path):
|
||
"""
|
||
烧录字幕到视频
|
||
|
||
Args:
|
||
video_path: 输入视频路径
|
||
srt_path: 字幕文件路径
|
||
output_path: 输出路径
|
||
|
||
Returns:
|
||
True if success
|
||
"""
|
||
# Windows路径转义
|
||
srt_escaped = srt_path.replace('\\', '/').replace('D:/', 'D\\:/')
|
||
|
||
filter_str = f"subtitles='{srt_escaped}':force_style='{SUBTITLE_STYLE}'"
|
||
|
||
cmd = f'"{FFMPEG_CMD}" -y -i "{video_path}" -vf "{filter_str}" -c:a copy -y "{output_path}"'
|
||
|
||
success = run_cmd(cmd)
|
||
|
||
if success:
|
||
logger.info(f"Burned subtitles -> {output_path}")
|
||
else:
|
||
logger.error(f"Failed to burn subtitles")
|
||
|
||
return success
|
||
|
||
|
||
def burn_dual_subtitles(video_path, title_srt_path, content_srt_path, output_path, title_fontsize=90, title_color="FFFF00", subtitle_fontsize=24, subtitle_color="FFFFFF"):
|
||
"""
|
||
烧录两层字幕到视频(标题在屏幕正中,正文在下方)
|
||
|
||
Args:
|
||
video_path: 输入视频路径
|
||
title_srt_path: 标题字幕文件路径
|
||
content_srt_path: 正文字幕文件路径
|
||
output_path: 输出路径
|
||
title_fontsize: 标题字号
|
||
title_color: 标题颜色(HTML格式如FFFF00)
|
||
subtitle_fontsize: 正文字号
|
||
subtitle_color: 正文颜色
|
||
|
||
Returns:
|
||
True if success
|
||
"""
|
||
# Windows路径转义
|
||
title_escaped = title_srt_path.replace('\\', '/').replace('D:/', 'D\\:/')
|
||
content_escaped = content_srt_path.replace('\\', '/').replace('D:/', 'D\\:/')
|
||
|
||
# 转换颜色格式:HTML (FFFF00) -> FFmpeg BGR (&H00FFFF)
|
||
def html_to_bgr(color):
|
||
if color.startswith('&H') or color.startswith('0x'):
|
||
return color
|
||
# HTML format like FFFF00 -> FFmpeg BGR format
|
||
r = int(color[0:2], 16)
|
||
g = int(color[2:4], 16)
|
||
b = int(color[4:6], 16)
|
||
return f"&H00{b:02X}{g:02X}{r:02X}"
|
||
|
||
title_bgr = html_to_bgr(title_color)
|
||
subtitle_bgr = html_to_bgr(subtitle_color)
|
||
|
||
# 标题样式:使用SRT+force_style,Alignment=5水平居中,垂直位置由MarginV控制
|
||
# 正文字样式:底部居中,24字号,白色,带描边
|
||
content_style = f"FontName=微软雅黑,FontSize={subtitle_fontsize},PrimaryColour={subtitle_bgr},Alignment=2,MarginV=20,Outline=1,Shadow=1"
|
||
|
||
# 使用两个独立字幕滤镜分别渲染,然后叠加
|
||
# 标题使用Alignment=5,MarginV=0(正中)
|
||
title_style = f"FontName=微软雅黑,FontSize={title_fontsize},PrimaryColour={title_bgr},Alignment=5,MarginV=0,Outline=3,Shadow=2"
|
||
|
||
# 使用两个字幕滤镜叠加,然后映射视频+原始音频
|
||
# 标题使用Alignment=5,MarginV=0(正中)
|
||
title_style = f"FontName=微软雅黑,FontSize={title_fontsize},PrimaryColour={title_bgr},Alignment=5,MarginV=0,Outline=3,Shadow=2"
|
||
|
||
# 使用两个字幕滤镜叠加
|
||
filter_str = f"[0:v]subtitles='{title_escaped}':force_style='{title_style}',subtitles='{content_escaped}':force_style='{content_style}'[out]"
|
||
|
||
# 保留原始音频 - 映射视频输出和原始音频
|
||
cmd = f'"{FFMPEG_CMD}" -y -i "{video_path}" -filter_complex "{filter_str}" -map "[out]" -map 0:a? -c:a copy -c:v libx264 -crf 18 -y "{output_path}"'
|
||
|
||
success = run_cmd(cmd)
|
||
|
||
if success:
|
||
logger.info(f"Burned dual subtitles (title+content) -> {output_path}")
|
||
else:
|
||
logger.error(f"Failed to burn dual subtitles")
|
||
|
||
return success
|
||
|
||
|
||
def extract_key_frames(video_path, timestamps, output_dir, prefix="frame"):
|
||
"""
|
||
从视频提取关键帧
|
||
|
||
Args:
|
||
video_path: 视频路径
|
||
timestamps: 时间戳列表
|
||
output_dir: 输出目录
|
||
prefix: 文件名前缀
|
||
|
||
Returns:
|
||
帧文件路径列表
|
||
"""
|
||
ensure_dir(output_dir)
|
||
frames = []
|
||
|
||
for i, ts in enumerate(timestamps):
|
||
output_path = os.path.join(output_dir, f"{prefix}_{i:03d}.jpg")
|
||
|
||
cmd = f'"{FFMPEG_CMD}" -y -ss {ts} -i "{video_path}" -vframes 1 -q:v 2 "{output_path}"'
|
||
|
||
if run_cmd(cmd) and os.path.exists(output_path):
|
||
frames.append(output_path)
|
||
else:
|
||
logger.warning(f"Failed to extract frame at {ts}s")
|
||
|
||
return frames
|
||
|
||
|
||
class VideoPipeline:
|
||
"""视频流水线封装"""
|
||
|
||
def __init__(self, config):
|
||
self.config = config
|
||
self.video_params = config.get('video_params', DEFAULT_VIDEO_PARAMS)
|
||
self.video_src = config.get('video_src')
|
||
self.output_dir = config.get('output_dir')
|
||
self.inter_dir = os.path.join(self.output_dir, 'intermediates')
|
||
|
||
ensure_dir(self.output_dir)
|
||
ensure_dir(self.inter_dir)
|
||
|
||
def extract_clips(self, clips):
|
||
"""
|
||
提取所有视频片段
|
||
|
||
Args:
|
||
clips: 片段配置列表
|
||
|
||
Returns:
|
||
片段路径列表
|
||
"""
|
||
clip_paths = []
|
||
fade_dur = self.video_params.get('fade_duration', 1)
|
||
|
||
for i, clip in enumerate(clips, 1):
|
||
clip_path = os.path.join(self.inter_dir, f"clip{i}.mp4")
|
||
fade_path = os.path.join(self.inter_dir, f"clip{i}_fade.mp4")
|
||
|
||
# 提取片段
|
||
success = extract_clip(
|
||
self.video_src,
|
||
clip['start'],
|
||
clip['end'],
|
||
clip_path,
|
||
fade_duration=0 # 先不添加淡出
|
||
)
|
||
|
||
if success and fade_dur > 0:
|
||
# 添加淡入淡出
|
||
from .utils import to_srt_time
|
||
duration = clip['end'] - clip['start']
|
||
fade_in_end = fade_dur
|
||
fade_out_start = max(0, duration - fade_dur)
|
||
|
||
cmd = f'"{FFMPEG_CMD}" -y -i "{clip_path}" '
|
||
cmd += f'-vf "fade=t=in:st=0:d={fade_dur},fade=t=out:st={fade_out_start}:d={fade_dur}" '
|
||
cmd += f'-c:v libx264 -crf 20 -c:a aac -y "{fade_path}"'
|
||
|
||
if run_cmd(cmd):
|
||
clip_paths.append(fade_path)
|
||
else:
|
||
# 淡出失败,使用原始片段
|
||
clip_paths.append(clip_path)
|
||
else:
|
||
clip_paths.append(clip_path)
|
||
|
||
return clip_paths
|
||
|
||
def merge(self, clip_paths, output_name="concat_merged.mp4"):
|
||
"""合并片段"""
|
||
output_path = os.path.join(self.output_dir, output_name)
|
||
return merge_clips(clip_paths, output_path, self.inter_dir)
|
||
|
||
def burn(self, video_path, srt_path, output_name="final.mp4"):
|
||
"""烧录字幕"""
|
||
output_path = os.path.join(self.output_dir, output_name)
|
||
return burn_subtitles(video_path, srt_path, output_path) |