Files
lesson-highlights/src/core/video.py
T

308 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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_styleAlignment=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)