feat(merge): 合并视频字幕

- 新增 merge_settings 组件用于视频字幕合并设置
- 实现视频和字幕文件的上传、匹配和排序功能
- 添加合并视频和字幕的逻辑,支持多文件合并- 优化用户界面,增加预览和错误处理功能
This commit is contained in:
linyqh 2024-12-05 00:56:09 +08:00
parent 0021a868b6
commit 52180d49c9
6 changed files with 373 additions and 3 deletions

1
.gitignore vendored
View File

@ -27,4 +27,5 @@ resource/scripts/*
resource/videos/*
resource/songs/*
resource/fonts/*
resource/srt/*
app/models/faster-whisper-large-v2/*

View File

@ -126,6 +126,15 @@ def public_dir(sub_dir: str = ""):
return d
def srt_dir(sub_dir: str = ""):
d = resource_dir(f"srt")
if sub_dir:
d = os.path.join(d, sub_dir)
if not os.path.exists(d):
os.makedirs(d)
return d
def run_in_background(func, *args, **kwargs):
def run():
try:

View File

@ -3,7 +3,7 @@ import os
import sys
from uuid import uuid4
from app.config import config
from webui.components import basic_settings, video_settings, audio_settings, subtitle_settings, script_settings, review_settings
from webui.components import basic_settings, video_settings, audio_settings, subtitle_settings, script_settings, review_settings, merge_settings
from webui.utils import cache, file_utils
from app.utils import utils
from app.models.schema import VideoClipParams, VideoAspect
@ -178,7 +178,9 @@ def main():
# 渲染基础设置面板
basic_settings.render_basic_settings(tr)
# 渲染合并设置
merge_settings.render_merge_settings(tr)
# 渲染主面板
panel = st.columns(3)
with panel[0]:

View File

@ -0,0 +1,218 @@
import os
import time
import math
import sys
import tempfile
import streamlit as st
from pathlib import Path
from typing import List, Dict, Tuple
from dataclasses import dataclass
from streamlit.runtime.uploaded_file_manager import UploadedFile
from webui.utils.merge_video import merge_videos_and_subtitles
from app.utils.utils import video_dir, srt_dir
@dataclass
class VideoSubtitlePair:
video_file: UploadedFile | None
subtitle_file: UploadedFile | None
base_name: str
order: int = 0
def save_uploaded_file(uploaded_file: UploadedFile, temp_dir: str) -> str:
"""Save uploaded file to temporary directory and return the file path"""
file_path = os.path.join(temp_dir, uploaded_file.name)
with open(file_path, "wb") as f:
f.write(uploaded_file.getvalue())
return file_path
def group_files(files: List[UploadedFile]) -> Dict[str, VideoSubtitlePair]:
"""Group uploaded files by their base names"""
pairs = {}
order_counter = 0
for file in files:
base_name = os.path.splitext(file.name)[0]
ext = os.path.splitext(file.name)[1].lower()
if base_name not in pairs:
pairs[base_name] = VideoSubtitlePair(None, None, base_name, order_counter)
order_counter += 1
if ext == ".mp4":
pairs[base_name].video_file = file
elif ext == ".srt":
pairs[base_name].subtitle_file = file
return pairs
def render_merge_settings(tr):
"""Render the merge settings section"""
with st.expander(tr("Video Subtitle Merge"), expanded=False):
# 上传文件区域
uploaded_files = st.file_uploader(
tr("Upload Video and Subtitle Files"),
type=["mp4", "srt"],
accept_multiple_files=True,
key="merge_files"
)
if uploaded_files:
all_pairs = group_files(uploaded_files)
if all_pairs:
st.write(tr("All Uploaded Files"))
# 初始化或更新session state中的排序信息
if 'file_orders' not in st.session_state:
st.session_state.file_orders = {
name: pair.order for name, pair in all_pairs.items()
}
st.session_state.needs_reorder = False
# 确保所有新文件都有排序值
for name, pair in all_pairs.items():
if name not in st.session_state.file_orders:
st.session_state.file_orders[name] = pair.order
# 移除不存在的文件的排序值
st.session_state.file_orders = {
k: v for k, v in st.session_state.file_orders.items()
if k in all_pairs
}
# 按照排序值对文件对进行排序
sorted_pairs = sorted(
all_pairs.items(),
key=lambda x: st.session_state.file_orders[x[0]]
)
# 计算需要多少行来显示所有视频每行5个
num_pairs = len(sorted_pairs)
num_rows = (num_pairs + 4) // 5 # 向上取整
# 遍历每一行
for row in range(num_rows):
# 创建5列
cols = st.columns(5)
# 在这一行中填充视频最多5个
for col_idx in range(5):
pair_idx = row * 5 + col_idx
if pair_idx < num_pairs:
base_name, pair = sorted_pairs[pair_idx]
with cols[col_idx]:
st.caption(base_name)
# 显示视频(如果存在)
if pair.video_file:
st.video(pair.video_file)
else:
st.warning(tr("Missing Video"))
# 添加排序输入框
order = st.number_input(
tr("Order"),
min_value=0,
value=st.session_state.file_orders[base_name],
key=f"order_{base_name}",
on_change=lambda: setattr(st.session_state, 'needs_reorder', True)
)
if order != st.session_state.file_orders[base_name]:
st.session_state.file_orders[base_name] = order
st.session_state.needs_reorder = True
# 显示字幕(如果存在)
if pair.subtitle_file:
subtitle_content = pair.subtitle_file.getvalue().decode('utf-8')
st.text_area(
"字幕预览",
value=subtitle_content,
height=150,
label_visibility="collapsed"
)
else:
st.warning(tr("Missing Subtitle"))
# 合并后的视频预览
merge_videos_result = None
# 只有当存在完整的配对时才显示按钮
complete_pairs = {k: v for k, v in all_pairs.items() if v.video_file and v.subtitle_file}
if complete_pairs:
# 创建按钮
cols = st.columns([1, 1, 3, 3, 3])
with cols[0]:
if st.button(tr("Reorder"), disabled=not st.session_state.needs_reorder, use_container_width=True):
st.session_state.needs_reorder = False
st.rerun()
with cols[1]:
if st.button(tr("Merge All Files"), type="primary", use_container_width=True):
try:
# 获取排序后的完整文件对
sorted_complete_pairs = sorted(
[(k, v) for k, v in complete_pairs.items()],
key=lambda x: st.session_state.file_orders[x[0]]
)
# 创建临时目录保存文件
with tempfile.TemporaryDirectory() as temp_dir:
# 保存上传的文件到临时目录
video_paths = []
subtitle_paths = []
for _, pair in sorted_complete_pairs:
video_path = save_uploaded_file(pair.video_file, temp_dir)
subtitle_path = save_uploaded_file(pair.subtitle_file, temp_dir)
video_paths.append(video_path)
subtitle_paths.append(subtitle_path)
# 获取输出目录, 文件名添加 MMSS 时间戳
output_video = os.path.join(video_dir(), f"merged_video_{time.strftime('%M%S')}.mp4")
output_subtitle = os.path.join(srt_dir(), f"merged_subtitle_{time.strftime('%M%S')}.srt")
with st.spinner(tr("Merging files...")):
# 合并文件
merge_videos_and_subtitles(
video_paths,
subtitle_paths,
output_video,
output_subtitle
)
success = True
error_msg = ""
# 检查输出文件是否成功生成
if not os.path.exists(output_video):
success = False
error_msg += tr("Failed to generate merged video. ")
if not os.path.exists(output_subtitle):
success = False
error_msg += tr("Failed to generate merged subtitle. ")
if success:
# 显示成功消息
st.success(tr("Merge completed!"))
merge_videos_result = (output_video, output_subtitle)
else:
st.error(error_msg)
except Exception as e:
error_message = str(e)
if "moviepy" in error_message.lower():
st.error(tr("Error processing video files. Please check if the videos are valid MP4 files."))
elif "pysrt" in error_message.lower():
st.error(tr("Error processing subtitle files. Please check if the subtitles are valid SRT files."))
else:
st.error(f"{tr('Error during merge')}: {error_message}")
with cols[2]:
# 提供视频和字幕的预览
if merge_videos_result:
with st.popover(tr("Preview Merged Video")):
st.video(merge_videos_result[0], subtitles=merge_videos_result[1])
st.code(f"{tr('Video Path')}: {merge_videos_result[0]}")
st.code(f"{tr('Subtitle Path')}: {merge_videos_result[1]}")
else:
st.warning(tr("No Matched Pairs Found"))

View File

@ -138,6 +138,31 @@
"Upload Script File": "上传脚本文件",
"Script Uploaded Successfully": "脚本上传成功",
"Invalid JSON format": "无效的JSON格式",
"Upload failed": "上传失败"
"Upload failed": "上传失败",
"Video Subtitle Merge": "**合并视频与字幕**",
"Upload Video and Subtitle Files": "上传视频和字幕文件",
"Matched File Pairs": "已匹配的文件对",
"Merge All Files": "合并所有文件",
"Merge Function Not Implemented": "合并功能待实现",
"No Matched Pairs Found": "未找到匹配的文件对",
"Missing Subtitle": "缺少对应的字幕文件",
"Missing Video": "缺少对应的视频文件",
"All Uploaded Files": "所有上传的文件",
"Order": "排序序号",
"Reorder": "重新排序",
"Merging files...": "正在合并文件...",
"Merge completed!": "合并完成!",
"Download Merged Video": "下载合并后的视频",
"Download Merged Subtitle": "下载合并后的字幕",
"Error during merge": "合并过程中出错",
"Failed to generate merged video.": "生成合并视频失败。",
"Failed to generate merged subtitle.": "生成合并字幕失败。",
"Error reading merged video file": "读取合并后的视频文件时出错",
"Error reading merged subtitle file": "读取合并后的字幕文件时出错",
"Error processing video files. Please check if the videos are valid MP4 files.": "处理视频文件时出错。请检查视频是否为有效的MP4文件。",
"Error processing subtitle files. Please check if the subtitles are valid SRT files.": "处理字幕文件时出错。请检查字幕是否为有效的SRT文件。",
"Preview Merged Video": "预览合并后的视频",
"Video Path": "视频路径",
"Subtitle Path": "字幕路径"
}
}

115
webui/utils/merge_video.py Normal file
View File

@ -0,0 +1,115 @@
"""
合并视频和字幕文件
"""
from moviepy.editor import VideoFileClip, concatenate_videoclips
import pysrt
import os
def get_video_duration(video_path):
"""获取视频时长(秒)"""
video = VideoFileClip(video_path)
duration = video.duration
video.close()
return duration
def adjust_subtitle_timing(subtitle_path, time_offset):
"""调整字幕时间戳"""
subs = pysrt.open(subtitle_path)
# 为每个字幕项添加时间偏移
for sub in subs:
sub.start.hours += int(time_offset / 3600)
sub.start.minutes += int((time_offset % 3600) / 60)
sub.start.seconds += int(time_offset % 60)
sub.start.milliseconds += int((time_offset * 1000) % 1000)
sub.end.hours += int(time_offset / 3600)
sub.end.minutes += int((time_offset % 3600) / 60)
sub.end.seconds += int(time_offset % 60)
sub.end.milliseconds += int((time_offset * 1000) % 1000)
return subs
def merge_videos_and_subtitles(video_paths, subtitle_paths, output_video_path, output_subtitle_path):
"""合并视频和字幕文件"""
if len(video_paths) != len(subtitle_paths):
raise ValueError("视频文件数量与字幕文件数量不匹配")
# 1. 合并视频
video_clips = []
accumulated_duration = 0
merged_subs = pysrt.SubRipFile()
try:
# 处理所有视频和字幕
for i, (video_path, subtitle_path) in enumerate(zip(video_paths, subtitle_paths)):
# 添加视频
print(f"处理视频 {i + 1}/{len(video_paths)}: {video_path}")
video_clip = VideoFileClip(video_path)
video_clips.append(video_clip)
# 处理字幕
print(f"处理字幕 {i + 1}/{len(subtitle_paths)}: {subtitle_path}")
if i == 0:
# 第一个字幕文件直接读取
current_subs = pysrt.open(subtitle_path)
else:
# 后续字幕文件需要调整时间戳
current_subs = adjust_subtitle_timing(subtitle_path, accumulated_duration)
# 合并字幕
merged_subs.extend(current_subs)
# 更新累计时长
accumulated_duration += video_clip.duration
# 判断视频是否存在,若已经存在不重复合并
if not os.path.exists(output_video_path):
print("合并视频中...")
final_video = concatenate_videoclips(video_clips)
# 保存合并后的视频
print("保存合并后的视频...")
final_video.write_videofile(output_video_path, audio_codec='aac')
# 保存合并后的字幕
print("保存合并后的字幕...")
merged_subs.save(output_subtitle_path, encoding='utf-8')
print("合并完成")
finally:
# 清理资源
for clip in video_clips:
clip.close()
def main():
# 示例用法
video_paths = [
"temp/1.mp4",
"temp/2.mp4",
"temp/3.mp4",
"temp/4.mp4",
"temp/5.mp4",
]
subtitle_paths = [
"temp/1.srt",
"temp/2.srt",
"temp/3.srt",
"temp/4.srt",
"temp/5.srt",
]
output_video_path = "temp/merged_video.mp4"
output_subtitle_path = "temp/merged_subtitle.srt"
merge_videos_and_subtitles(video_paths, subtitle_paths, output_video_path, output_subtitle_path)
if __name__ == "__main__":
main()