Initial commit: lesson-highlights generator
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
# -*- 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)
|
||||
Reference in New Issue
Block a user