From 52180d49c9c0f004b30d48161217dad44b545744 Mon Sep 17 00:00:00 2001 From: linyqh Date: Thu, 5 Dec 2024 00:56:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(merge):=20=E5=90=88=E5=B9=B6=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E5=AD=97=E5=B9=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 merge_settings 组件用于视频字幕合并设置 - 实现视频和字幕文件的上传、匹配和排序功能 - 添加合并视频和字幕的逻辑,支持多文件合并- 优化用户界面,增加预览和错误处理功能 --- .gitignore | 1 + app/utils/utils.py | 9 ++ webui.py | 6 +- webui/components/merge_settings.py | 218 +++++++++++++++++++++++++++++ webui/i18n/zh.json | 27 +++- webui/utils/merge_video.py | 115 +++++++++++++++ 6 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 webui/components/merge_settings.py create mode 100644 webui/utils/merge_video.py diff --git a/.gitignore b/.gitignore index f10a692..8ab0b48 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ resource/scripts/* resource/videos/* resource/songs/* resource/fonts/* +resource/srt/* app/models/faster-whisper-large-v2/* \ No newline at end of file diff --git a/app/utils/utils.py b/app/utils/utils.py index 887ac56..db0d248 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -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: diff --git a/webui.py b/webui.py index 1f4cb97..5d00d01 100644 --- a/webui.py +++ b/webui.py @@ -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]: diff --git a/webui/components/merge_settings.py b/webui/components/merge_settings.py new file mode 100644 index 0000000..6fbfbae --- /dev/null +++ b/webui/components/merge_settings.py @@ -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")) diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json index 70389c6..0e62ce0 100644 --- a/webui/i18n/zh.json +++ b/webui/i18n/zh.json @@ -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": "字幕路径" } } diff --git a/webui/utils/merge_video.py b/webui/utils/merge_video.py new file mode 100644 index 0000000..65e13aa --- /dev/null +++ b/webui/utils/merge_video.py @@ -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()