diff --git a/SimpleCutPy.spec b/SimpleCutPy.spec index dd1c80f..d6088e6 100644 --- a/SimpleCutPy.spec +++ b/SimpleCutPy.spec @@ -6,7 +6,7 @@ a = Analysis( pathex=['src'], binaries=[('assets/ffmpeg.exe', 'assets')], datas=[], - hiddenimports=['wx', 'src', 'pymediainfo'], + hiddenimports=['wx', 'src', 'pymediainfo', 'pydantic'], hookspath=[], hooksconfig={}, runtime_hooks=[], diff --git a/pyproject.toml b/pyproject.toml index 5512fab..17bc951 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,12 @@ version = "0.4.3" description = "A simple video cut tool based on wxpython, ffmpeg" readme = "README.md" requires-python = "==3.12.*" -dependencies = ["logging>=0.4.9.6", "pymediainfo>=7.0.1", "wxpython>=4.2.2"] +dependencies = [ + "logging>=0.4.9.6", + "pydantic>=2.12.5", + "pymediainfo>=7.0.1", + "wxpython>=4.2.2", +] [dependency-groups] dev = [ diff --git a/src/SimpleCutMainFrame.py b/src/SimpleCutMainFrame.py index 9833546..cc9be72 100644 --- a/src/SimpleCutMainFrame.py +++ b/src/SimpleCutMainFrame.py @@ -1,48 +1,48 @@ +# TODO: 文件路径其实没有把文件名显示出来 + """Subclass of MainFrame, which is generated by wxFormBuilder.""" +from export.model import VideoFile +import export +from controller.core import CoreController import logging import os -import subprocess -import sys -import threading -import time import wx -from pymediainfo import MediaInfo import meta import SimpleCutPy -from command import concat_filter, merge_filestream_audio_channel from message import ExportMessage, WorkStateEnum -from model import VideoModel, VideoSequenceModel class FileDropTarget(wx.FileDropTarget): - def __init__(self, target): + def __init__(self, target: "SimpleCutPyMainFrame"): wx.FileDropTarget.__init__(self) self.target = target def OnDropFiles(self, x, y, filenames): + logging.debug(f"FileDropTarget 拖拽文件:{filenames}") for file in filenames: - self.target.append_files(file, file) + filepath, filename = os.path.split(file) + logging.debug(f"解析文件:{file} -> 路径: {filepath}, 文件名: {filename}") + self.target.core_controller.add_file(filename, filepath) + self.target.update_video_sequence_view() return True -# TODO: 重写配置的参数验证,建立物品类和导出配置 ExportConfig - - # Implementing MainFrame class SimpleCutPyMainFrame(SimpleCutPy.MainFrame): def __init__(self, parent=None): SimpleCutPy.MainFrame.__init__(self, parent) + # 设置标题 + self.SetTitle(f"Simple Cut Py {meta.VERSION}") + # 设置拖拽文件 self.list_ctrl.SetDropTarget(FileDropTarget(self)) self.first_selected_index = 0 - # self.item_list: list[dict] = [] # 列表是控件上的映射,列表的物品顺序就是控件上物品的顺序 - self.video_sequence: VideoSequenceModel = VideoSequenceModel() # list_ctrl 控件添加列 self.list_ctrl.InsertColumn(0, "序号", width=40) @@ -54,377 +54,329 @@ def __init__(self, parent=None): # 标记版本 self.VersionText.SetLabelText(f"Simple Cut Py 版本号\n{meta.VERSION}") - # Handlers for MainFrame events. + self.core_controller = CoreController(self) - # TODO: 参数初始化 - self.ExportBitCtrl.SetValue("6") + self.on_size_control_mode_change(None) - # 线程字典 - self.working_thread: dict[str, threading.Thread] = {} + def _bind_event(self): + pass def on_add_file_button_click(self, event): # 文件选择对话框 file_dlg = wx.FileDialog(self, "选择导入的文件", "", "", "*.mp4", wx.FD_OPEN) if file_dlg.ShowModal() == wx.ID_OK: - # 文件导入 - # {NO, filename, startTime, endTime, path} + # 导入文件 + filepath = file_dlg.GetPath() + for filename in file_dlg.GetFilenames(): + self.core_controller.add_file(filename, filepath) - # 将导入文件数据转为字典 - filename = file_dlg.GetFilename() - path = file_dlg.GetPath() + logging.debug("导入文件:{}, {}".format(filename, filepath)) - self.append_files(filename, path) - - logging.debug( - "导入文件:{}, {}".format(file_dlg.GetFilename(), file_dlg.GetPath()) - ) + self.update_video_sequence_view() file_dlg.Destroy() - def list_ctrl_on_drop_files(self, event): - files = event.GetFiles() - - # 防止拖空文件 - if len(files) <= 0: - return - - logging.debug(f"拖拽文件:{files}") - - for filename in files: - item_no = self.list_ctrl.GetItemCount() - self.add_files(item_no, filename, filename) - def on_remove_file_button_click(self, event): # 删除列表中的项 - index = self.first_selected_index + target_idx = self.core_controller.first_select_index - if index <= -1: + if target_idx <= -1: return # 如果没有选中 - if index >= len(self.video_sequence): + if target_idx >= self.core_controller.sequence_length(): return # 如果超出范围 - self.video_sequence.pop_video(index) - - # 删除界面中的项 - - self.list_ctrl.DeleteItem(index) + self.core_controller.remove_file(target_idx) - # 删除以后进行序号重排 - for i in range(len(self.video_sequence)): - if i < index: - continue - - # 从删除项开始后面的每一个物品都重新加载 - self.update_video_model_item(i) + self.update_video_sequence_view() # 选中 index - self.list_ctrl.Select(index) + if target_idx < self.core_controller.sequence_length(): + self.list_ctrl.Select(target_idx) + elif self.core_controller.sequence_length() > 0: + self.list_ctrl.Select(self.core_controller.sequence_length() - 1) def on_move_up_file_button_click(self, event): - value = self.first_selected_index + logging.debug( + f"on_move_up_file_button_click: {self.core_controller.first_select_index}" + ) + + idx = self.core_controller.first_select_index - if value == -1: + if idx == -1: return # 如果没有选中 - if value == 0: + if idx == 0: wx.MessageBox( "选中素材已置顶。", "错误", style=wx.YES_DEFAULT | wx.ICON_QUESTION ) return # 如果是第一个物品 - self.video_sequence.swap_item(value, value - 1) + self.core_controller.swap_file(idx, idx - 1) - self.update_video_model_item(value) - self.update_video_model_item(value - 1) + self.update_video_sequence_view() - self.list_ctrl.Select(self.first_selected_index, on=0) # 取消原来的选中 - self.list_ctrl.Select(self.first_selected_index - 1) + self.list_ctrl.Select(idx, on=0) # 取消原来的选中 + self.list_ctrl.Select(idx - 1) def on_move_down_file_button_click(self, event): - value = self.first_selected_index + logging.debug( + f"on_move_down_file_button_click: {self.core_controller.first_select_index}" + ) + + idx = self.core_controller.first_select_index - if value == -1: + if idx == -1: return # 如果没有选中 - if value == self.list_ctrl.GetItemCount() - 1: + if idx == self.list_ctrl.GetItemCount() - 1: wx.MessageBox( "选中素材在最末端。", "错误", style=wx.YES_DEFAULT | wx.ICON_QUESTION ) return # 如果是最后一个 - self.video_sequence.swap_item( - self.first_selected_index, self.first_selected_index + 1 - ) + self.core_controller.swap_file(idx, idx + 1) - self.update_video_model_item(value) - self.update_video_model_item(value + 1) + self.update_video_sequence_view() # 选中转移 - self.list_ctrl.Select(self.first_selected_index, on=0) # 取消原来的选中 - self.list_ctrl.Select(self.first_selected_index + 1) + self.list_ctrl.Select(idx, on=0) # 取消原来的选中 + self.list_ctrl.Select(idx + 1) def on_export_button_click(self, event): - # TODO: 加了码率设置的功能,别忘了测试 - # TODO: item list 重写 - - # 从界面读取导出文件名、路径、码率 - export_name = self.ExportNameCtrl.GetValue() - export_path = self.ExportPathCtrl.GetValue() - export_mbps = self.ExportBitCtrl.GetValue() - export_amix = self.AmixCheckBox.IsChecked() - export_double_output = self.DoubleOutputBox.IsChecked() - - # 导出码率设置为空则使用 6 mbps - if export_mbps == "": - export_mbps = 6 - - # 导出文件名为空则使用时间 - if export_name == "": - export_name = str(time.strftime("No Title %Y.%m.%d - %H.%M.output.mp4")) - - # 导出路径不为空则更改导出目录 - if not export_path == "": - # os.chdir(export_path) - export_name = export_path + "/" + export_name - else: - # 默认使用第一个文件的目录 - path = os.path.dirname(self.video_sequence[0].path) - export_name = path + "/" + export_name - - paths = [export_name] - - # 如果没有后缀 添加类型后缀 - if "." not in export_name: - export_name += ".mp4" - - # 获取后缀 - path_without_suffix, suffix = os.path.splitext(export_name) + # 获取文件名路径 + self.core_controller.task.export_file_name = self.ExportNameCtrl.GetValue() + self.core_controller.task.export_file_path = self.ExportPathCtrl.GetValue() - if export_double_output: - # 如果导出双倍输出,则给文件名添加后缀 - paths.append(path_without_suffix + "_WITHAMIX" + suffix) + # 读取配置 + self.get_export_config() # 导出 - for it in paths: - # 第一个输出文件使用用户设置的amix状态 - # 第二个输出文件(带_WITHAMIX后缀)强制使用amix=True - if "_WITHAMIX" in it: - # 对于带_WITHAMIX后缀的输出,强制使用多音轨合并 - t = threading.Thread( - target=self.export_video_file, args=(True, export_mbps, it) - ) - else: - # 使用用户设置的多音轨合并状态 - t = threading.Thread( - target=self.export_video_file, args=(False, export_mbps, it) - ) - self.working_thread[it] = t - t.start() + self.core_controller.export_sequence() self.ExportBtn.Disable() return - def export_video_file(self, export_amix, export_mbps, export_name): - # TODO: item list 重写 - # 导出命令 - - # 获取ffmpeg路径 - 优先使用打包后的资源路径 - if getattr(sys, "frozen", False): - # 打包后环境 - base_path = sys._MEIPASS # type: ignore - else: - # 开发环境 - base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - ffmpeg_path = os.path.join(base_path, "assets", "ffmpeg.exe") - console_command = f'"{ffmpeg_path}" ' - filter_complex_string = "-filter_complex " - filter_complex_filters: list[str] = [] - concat_inputs: list[str] = [] - for index, item in enumerate(self.video_sequence.video_list): - no = index - start_time = item.start_time - end_time = item.end_time - item_path = item.path - - # 开始、结束时间以及路径的命令行参数生成 - time_param = [] - if start_time != "": - time_param.append(f"-ss {start_time}") - if end_time != "": - time_param.append(f"-to {end_time}") - time_param.append(f'-i "{item_path}"') - time_string = " ".join(time_param) - - console_command += time_string + " " - - # concat_inputs 的参数生成 - concat_inputs.append(f"{no}:v") - - media_info = MediaInfo.parse(item_path) - audio_tracks_number = len(media_info.audio_tracks) - if audio_tracks_number > 0 and export_amix: - # 多音轨,合并 - # amix_filter - filter_complex_filters.append( - merge_filestream_audio_channel( - f"{no}", audio_tracks_number, f"{no}a" - ) - ) - concat_inputs.append(f"{no}a") - else: - # 单音轨 - concat_inputs.append(f"{no}:a") - # 使用 concat 滤镜 - concat_string = concat_filter(concat_inputs, "v", "a") - filter_complex_filters.append(concat_string) - # 拼接 filter_complex 命令行参数,拼接滤镜 - console_command += ( - filter_complex_string + f'"{";".join(filter_complex_filters)}"' - ) - console_command += ' -map "[v]" -map "[a]"' - # 拼接全指令 - if os.path.split(export_name)[1] == "": - export_name += ".mp4" - - console_command += f' -b:v {export_mbps}M "{export_name}"' - logging.info(f"导出命令:{console_command}") - - # 执行命令 - try: - subprocess.run( - console_command, - shell=False, - check=True, - creationflags=subprocess.CREATE_NO_WINDOW, - ) - - # 完成命令,发送事件 - wx.CallAfter( - self.on_export_done, - ExportMessage(WorkStateEnum.SUCCESS, "导出完成", export_name), - ) - except subprocess.CalledProcessError as e: - # 导出失败,发送事件 - wx.CallAfter(self.on_export_done, ExportMessage(WorkStateEnum.FAIL, e)) - def on_open_project_website_button_click(self, event): # TODO: Implement ProjectWebBtnOnClick pass - def add_files(self, item_no, filename, path): - """ - 添加文件。将文件添加到物品列表,并刷新显示在界面上 - :param item_no: 序号 - :param filename: 文件名 - :param path: 文件路径 - :return: 空 - """ - # 构建物品字典 - item = VideoModel(path, filename) - - # 加到物品表 - self.video_sequence.append_video(item) - - # 显示数据在界面 - index = self.list_ctrl.InsertItem(item_no, item_no) - self.update_video_model_item(index) - - def append_files(self, filename, path): - item_no = self.list_ctrl.GetItemCount() - self.add_files(item_no, filename, path) - def on_clear_all_button_click(self, event): - self.video_sequence.clear_all() - logging.debug(f"clear all video: {self.video_sequence.video_list}") - self.update_sequence_model() + self.core_controller.clear_all_files() + logging.debug( + f"clear all video: {self.core_controller.task.video_sequence.get_video_list()}" + ) + self.update_video_sequence_view() def on_start_time_ctrl_text(self, event): """修改开始时间输入框的时候修改itemlist的start_time""" - index = self.first_selected_index + index = self.core_controller.first_select_index + value = self.StartTimeCtrl.GetValue() - self.video_sequence[index].start_time = value + # 尝试更新数据 + videofile = self.core_controller.get_file(index) + videofile.start_time = self.core_controller.format_time(value) - self.update_video_model_item(index) + self.update_video_file_view(index, videofile) def on_end_time_ctrl_text(self, event): """修改结束时间输入框的时候修改itemlist的end_time""" - index = self.first_selected_index + index = self.core_controller.first_select_index + value = self.EndTimeCtrl.GetValue() - self.video_sequence[index].end_time = value + # 尝试更新数据 + videofile = self.core_controller.get_file(index) + videofile.end_time = self.core_controller.format_time(value) - self.update_video_model_item(index) + self.update_video_file_view(index, videofile) def on_list_item_selected(self, event): index = self.list_ctrl.GetFirstSelected() - self.first_selected_index = index + self.core_controller.first_select_index = index # 获取选中的物品时间,同步到输入框 - self.StartTimeCtrl.SetValue(self.video_sequence[index].start_time) - self.EndTimeCtrl.SetValue(self.video_sequence[index].end_time) + self.StartTimeCtrl.SetValue(self.core_controller.get_file(index).start_time) + self.EndTimeCtrl.SetValue(self.core_controller.get_file(index).end_time) logging.debug( f"Selected Item Index: {index}, \ - Selected Item no: {self.video_sequence[index]}" + Selected Item no: {self.core_controller.get_file(index)}" ) - def update_video_model_item(self, no): - """重载物品""" - self.load_video_model_item(self.video_sequence[no], no) - - def update_sequence_model(self): - """重载序列""" - self.load_sequence_model(self.video_sequence) - - def load_video_model_item(self, load_item: VideoModel, list_ctrl_index: int): - """ - 将 VideoModel 载入到 列表item上 - :param load_item: 载入的物品 - :param list_ctrl_index: 载入在控件的行数 - :return: 无 - """ - self.list_ctrl.SetItem(list_ctrl_index, 0, str(list_ctrl_index)) - self.list_ctrl.SetItem(list_ctrl_index, 1, load_item.filename) - - start_time = "开头" if load_item.start_time == "" else load_item.start_time - end_time = "结尾" if load_item.end_time == "" else load_item.end_time - self.list_ctrl.SetItem(list_ctrl_index, 2, start_time) - self.list_ctrl.SetItem(list_ctrl_index, 3, end_time) - self.list_ctrl.SetItem(list_ctrl_index, 4, load_item.path) - - def load_sequence_model(self, sequence: VideoSequenceModel): - """ - 把物品列表上的所有物品载入到用户界面的控件上 - :return: - """ - # 清除控件 - self.list_ctrl.DeleteAllItems() - - # 重新载入 - for index, item in enumerate(sequence.video_list): - logging.debug(f"load item: {index},{item}") - self.list_ctrl.InsertItem(index, index) - self.load_video_model_item(item, index) - def on_export_done(self, msg: ExportMessage): logging.debug(f"Export Done: {msg}") if msg.state == WorkStateEnum.SUCCESS: wx.MessageBox("导出成功", "提示", wx.OK | wx.ICON_INFORMATION) - if msg.export_name != "": - self.working_thread.pop(msg.export_name) elif msg.state == WorkStateEnum.FAIL: logging.error(f"Export Error: {msg.message}") wx.MessageBox("导出失败", "提示", wx.OK | wx.ICON_INFORMATION) - if len(self.working_thread) == 0: + if len(self.core_controller.working_thread) == 0: self.ExportBtn.Enable() return + def on_size_control_mode_change(self, event): + size_control_mode = "none" + match self.SizeControlMode.GetSelection(): + case 0: + size_control_mode = "x264" + case 1: + size_control_mode = "mbps" + case _: + size_control_mode = "none" + + logging.debug(f"SizeControlMode: {size_control_mode}") + + # 如果不是 mbps 则隐藏,否则显示 + if size_control_mode == "mbps": + self.MbpsCtrl.Enable() + else: + self.MbpsCtrl.Disable() + + return + + def update_video_file_view(self, index: int, file: VideoFile): + """将 VideoFile 载入到 列表item上 + + Args: + index (int): 列表项索引 + file (VideoFile): 要载入的视频文件 + """ + logging.debug(f"update_video_file_view: {file}, index: {index}") + # 确保列表控件中有足够的项 + while index >= self.list_ctrl.GetItemCount(): + self.list_ctrl.InsertItem(self.list_ctrl.GetItemCount(), "") + + self.list_ctrl.SetItem(index, 0, str(index + 1)) + self.list_ctrl.SetItem(index, 1, file.file_name) + self.list_ctrl.SetItem(index, 2, file.start_time) + self.list_ctrl.SetItem(index, 3, file.end_time) + self.list_ctrl.SetItem(index, 4, file.file_path) + + def update_video_sequence_view(self, index=-1) -> None: + """更新视频序列视图 + + Args: + index (int, optional): 要更新的视频序列索引. 默认-1表示更新所有. + """ + sequence = self.core_controller.task.video_sequence.get_video_list() + logging.debug(f"update_video_sequence_view: {index}, sequence: {sequence}") + if index == -1: + # 清空列表,然后重新添加所有条目,避免空白条目 + self.list_ctrl.DeleteAllItems() + for i, item in enumerate(sequence): + # 直接添加到列表末尾 + self.list_ctrl.InsertItem(i, str(i + 1)) + self.list_ctrl.SetItem(i, 1, item.file_name) + self.list_ctrl.SetItem(i, 2, item.start_time) + self.list_ctrl.SetItem(i, 3, item.end_time) + self.list_ctrl.SetItem(i, 4, item.file_path) + return + + # 只更新单个条目 + if index < len(sequence): + self.update_video_file_view(index, sequence[index]) + + def update_export_config_view(self) -> None: + """更新导出配置视图""" + export_config = self.core_controller.task.export_config + + match export_config.size_control: + case export.model.X264Config(): + self.SizeControlMode.SetSelection(0) + case export.model.MbpsConfig(): + self.SizeControlMode.SetSelection(1) + self.MbpsCtrl.SetValue(str(export_config.size_control.mbps)) + case None: + self.SizeControlMode.SetSelection(2) + case _: + self.SizeControlMode.SetSelection(0) + + match export_config.multi_track_mode: + case "first": + self.MultiTrackMode.SetSelection(0) + case "amix": + self.MultiTrackMode.SetSelection(1) + case "export_both": + self.MultiTrackMode.SetSelection(2) + case _: + self.MultiTrackMode.SetSelection(0) + + def get_export_config(self) -> None: + """ + 获取导出配置到模型 + """ + size_control_mode = self.size_control_idx_to_enum( + self.SizeControlMode.GetSelection() + ) + + match size_control_mode: + case "x264": + size_control = export.model.X264Config() + case "mbps": + size_control = export.model.MbpsConfig( + mbps=float(self.MbpsCtrl.GetValue()) + ) + case "none": + size_control = None + case _: + size_control = None + + multi_track_mode = self.multi_track_select_idx_to_enum( + self.MultiTrackMode.GetSelection() + ) + + export_config = export.model.ExportConfig( + size_control=size_control, + multi_track_mode=multi_track_mode, + ) + + self.core_controller.setup_export_config(export_config) + + @staticmethod + def size_control_idx_to_enum(idx: int): + """将大小控制模式选择框索引转换为枚举值 + + Args: + idx (int): 大小控制模式选择框索引 + + Returns: + SizeControlMode: 大小控制模式枚举值 + """ + match idx: + case 0: + return "x264" + case 1: + return "mbps" + case 2: + return "none" + case _: + return "none" + + @staticmethod + def multi_track_select_idx_to_enum(idx: int): + """将多轨道模式选择框索引转换为枚举值 + + Args: + idx (int): 多轨道模式选择框索引 + + Returns: + MultiTrackMode: 多轨道模式枚举值 + """ + match idx: + case 0: + return "first" + case 1: + return "amix" + case 2: + return "export_both" + case _: + return "first" + if __name__ == "__main__": App = wx.App() diff --git a/src/SimpleCutPy.fbp b/src/SimpleCutPy.fbp index 25d102a..6847138 100644 --- a/src/SimpleCutPy.fbp +++ b/src/SimpleCutPy.fbp @@ -124,7 +124,7 @@ 素材设置 - 0 + 1 1 1 @@ -243,7 +243,6 @@ - list_ctrl_on_drop_files on_list_item_selected @@ -928,7 +927,7 @@ 导出设置 - 1 + 0 1 1 @@ -1166,7 +1165,7 @@ 0 0 wxID_ANY - 导出码率(Mbps): + 文件大小控制: 0 0 @@ -1195,6 +1194,145 @@ -1 + + 5 + wxALL + 0 + + 1 + 1 + 1 + 1 + + + + + + + + 1 + 0 + "x264" "mbps" "不控制" + 1 + + 1 + 0 + Dock + 0 + Left + 0 + 1 + + 1 + + 0 + 0 + wxID_ANY + + 0 + + + 0 + 200,-1 + 1 + SizeControlMode + 1 + + + protected + 1 + + Resizable + 0 + 1 + + + ; ; forward_declare + 0 + + + wxFILTER_NONE + wxDefaultValidator + + + + + on_size_control_mode_change + + + + + + 5 + wxEXPAND + 1 + + + bSizer121 + wxHORIZONTAL + none + + 5 + wxALL + 0 + + 1 + 1 + 1 + 1 + + + + + + + + 1 + 0 + 1 + + 1 + 0 + Dock + 0 + Left + 0 + 1 + + 1 + + 0 + 0 + wxID_ANY + 导出码率(Mbps): + 0 + + 0 + + + 0 + + 1 + m_staticText811 + 1 + + + protected + 1 + + Resizable + 1 + + + ; ; forward_declare + 0 + + + + + -1 + + 5 wxALL @@ -1236,7 +1374,7 @@ 0 1 - ExportBitCtrl + MbpsCtrl 1 @@ -1400,82 +1538,20 @@ - - 5 - wxALL - 0 - - 1 - 1 - 1 - 1 - - - - - - - - 1 - 0 - 1 - - 1 - 0 - Dock - 0 - Left - 0 - 1 - - 1 - - 0 - 0 - wxID_ANY - 帮助: 1. 在不写导出文件名的情况下,会默认导出文件为”No Title - 日期时间.mp4" 2. 在不填写具体路径的情况下,会默认导出到第一个素材所在的目录。 3. 默认的导出码率是 6 Mbps - 0 - - 0 - - - 0 - - 1 - m_staticText7 - 1 - - - protected - 1 - - Resizable - 1 - - - ; ; forward_declare - 0 - - - - - -1 - - 5 wxEXPAND 1 - bSizer16 + bSizer122 wxHORIZONTAL none - + 5 wxALL 0 - + 1 1 1 @@ -1484,20 +1560,15 @@ - 0 - 1 0 1 1 - - 0 0 - Dock 0 Left @@ -1505,13 +1576,11 @@ 1 1 - 0 0 wxID_ANY - 导出 - + 多音轨处理: 0 0 @@ -1520,15 +1589,13 @@ 0 1 - ExportBtn + m_staticText812 1 protected 1 - - Resizable 1 @@ -1536,21 +1603,17 @@ ; ; forward_declare 0 - - wxFILTER_NONE - wxDefaultValidator - - on_export_button_click + -1 5 wxALL 0 - + 1 1 1 @@ -1564,7 +1627,7 @@ 1 0 - 1 + "只选择第一个音轨" "全部合并为单音轨" "上面两个都要(两次输出)" 1 1 @@ -1580,15 +1643,14 @@ 0 0 wxID_ANY - 多音轨合并 0 0 - + 200,-1 1 - AmixCheckBox + MultiTrackMode 1 @@ -1596,6 +1658,7 @@ 1 Resizable + 0 1 @@ -1611,11 +1674,22 @@ - + + + + 5 + wxEXPAND + 1 + + + bSizer16 + wxHORIZONTAL + none + 5 wxALL 0 - + 1 1 1 @@ -1624,16 +1698,20 @@ + 0 + 1 0 - 0 1 1 + + 0 0 + Dock 0 Left @@ -1641,11 +1719,14 @@ 1 1 + 0 0 wxID_ANY - 双份输出 + 导出 + + 0 0 @@ -1653,13 +1734,15 @@ 0 1 - DoubleOutputBox + ExportBtn 1 protected 1 + + Resizable 1 @@ -1674,6 +1757,7 @@ + on_export_button_click diff --git a/src/SimpleCutPy.py b/src/SimpleCutPy.py index 514d42e..5fc45a6 100644 --- a/src/SimpleCutPy.py +++ b/src/SimpleCutPy.py @@ -88,7 +88,7 @@ def __init__( self, parent ): self.m_panel2.SetSizer( bSizer4 ) self.m_panel2.Layout() bSizer4.Fit( self.m_panel2 ) - self.m_notebook1.AddPage( self.m_panel2, u"素材设置", False ) + self.m_notebook1.AddPage( self.m_panel2, u"素材设置", True ) self.m_panel3 = wx.Panel( self.m_notebook1, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL ) bSizer5 = wx.BoxSizer( wx.VERTICAL ) @@ -109,17 +109,34 @@ def __init__( self, parent ): bSizer12 = wx.BoxSizer( wx.HORIZONTAL ) - self.m_staticText81 = wx.StaticText( self.m_panel3, wx.ID_ANY, u"导出码率(Mbps):", wx.DefaultPosition, wx.DefaultSize, 0 ) + self.m_staticText81 = wx.StaticText( self.m_panel3, wx.ID_ANY, u"文件大小控制:", wx.DefaultPosition, wx.DefaultSize, 0 ) self.m_staticText81.Wrap( -1 ) bSizer12.Add( self.m_staticText81, 0, wx.ALL, 5 ) - self.ExportBitCtrl = wx.TextCtrl( self.m_panel3, wx.ID_ANY, wx.EmptyString, wx.DefaultPosition, wx.Size( 295,-1 ), 0 ) - bSizer12.Add( self.ExportBitCtrl, 0, wx.ALL, 5 ) + SizeControlModeChoices = [ u"x264", u"mbps", u"不控制" ] + self.SizeControlMode = wx.Choice( self.m_panel3, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, SizeControlModeChoices, 0 ) + self.SizeControlMode.SetSelection( 0 ) + self.SizeControlMode.SetMinSize( wx.Size( 200,-1 ) ) + + bSizer12.Add( self.SizeControlMode, 0, wx.ALL, 5 ) bSizer5.Add( bSizer12, 1, wx.EXPAND, 5 ) + bSizer121 = wx.BoxSizer( wx.HORIZONTAL ) + + self.m_staticText811 = wx.StaticText( self.m_panel3, wx.ID_ANY, u"导出码率(Mbps):", wx.DefaultPosition, wx.DefaultSize, 0 ) + self.m_staticText811.Wrap( -1 ) + + bSizer121.Add( self.m_staticText811, 0, wx.ALL, 5 ) + + self.MbpsCtrl = wx.TextCtrl( self.m_panel3, wx.ID_ANY, wx.EmptyString, wx.DefaultPosition, wx.Size( 295,-1 ), 0 ) + bSizer121.Add( self.MbpsCtrl, 0, wx.ALL, 5 ) + + + bSizer5.Add( bSizer121, 1, wx.EXPAND, 5 ) + bSizer101 = wx.BoxSizer( wx.HORIZONTAL ) self.export_ctrl1 = wx.StaticText( self.m_panel3, wx.ID_ANY, u"导出路径:", wx.DefaultPosition, wx.Size( -1,25 ), 0 ) @@ -135,23 +152,28 @@ def __init__( self, parent ): bSizer5.Add( bSizer101, 1, wx.EXPAND, 5 ) - self.m_staticText7 = wx.StaticText( self.m_panel3, wx.ID_ANY, u"帮助:\n1. 在不写导出文件名的情况下,会默认导出文件为”No Title - 日期时间.mp4\"\n2. 在不填写具体路径的情况下,会默认导出到第一个素材所在的目录。\n3. 默认的导出码率是 6 Mbps", wx.DefaultPosition, wx.DefaultSize, 0 ) - self.m_staticText7.Wrap( -1 ) + bSizer122 = wx.BoxSizer( wx.HORIZONTAL ) + + self.m_staticText812 = wx.StaticText( self.m_panel3, wx.ID_ANY, u"多音轨处理:", wx.DefaultPosition, wx.DefaultSize, 0 ) + self.m_staticText812.Wrap( -1 ) + + bSizer122.Add( self.m_staticText812, 0, wx.ALL, 5 ) - bSizer5.Add( self.m_staticText7, 0, wx.ALL, 5 ) + MultiTrackModeChoices = [ u"只选择第一个音轨", u"全部合并为单音轨", u"上面两个都要(两次输出)" ] + self.MultiTrackMode = wx.Choice( self.m_panel3, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, MultiTrackModeChoices, 0 ) + self.MultiTrackMode.SetSelection( 0 ) + self.MultiTrackMode.SetMinSize( wx.Size( 200,-1 ) ) + + bSizer122.Add( self.MultiTrackMode, 0, wx.ALL, 5 ) + + + bSizer5.Add( bSizer122, 1, wx.EXPAND, 5 ) bSizer16 = wx.BoxSizer( wx.HORIZONTAL ) self.ExportBtn = wx.Button( self.m_panel3, wx.ID_ANY, u"导出", wx.DefaultPosition, wx.DefaultSize, 0 ) bSizer16.Add( self.ExportBtn, 0, wx.ALL, 5 ) - self.AmixCheckBox = wx.CheckBox( self.m_panel3, wx.ID_ANY, u"多音轨合并", wx.DefaultPosition, wx.DefaultSize, 0 ) - self.AmixCheckBox.SetValue(True) - bSizer16.Add( self.AmixCheckBox, 0, wx.ALL, 5 ) - - self.DoubleOutputBox = wx.CheckBox( self.m_panel3, wx.ID_ANY, u"双份输出", wx.DefaultPosition, wx.DefaultSize, 0 ) - bSizer16.Add( self.DoubleOutputBox, 0, wx.ALL, 5 ) - bSizer5.Add( bSizer16, 1, wx.EXPAND, 5 ) @@ -159,7 +181,7 @@ def __init__( self, parent ): self.m_panel3.SetSizer( bSizer5 ) self.m_panel3.Layout() bSizer5.Fit( self.m_panel3 ) - self.m_notebook1.AddPage( self.m_panel3, u"导出设置", True ) + self.m_notebook1.AddPage( self.m_panel3, u"导出设置", False ) self.m_panel41 = wx.Panel( self.m_notebook1, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL ) bSizer13 = wx.BoxSizer( wx.VERTICAL ) @@ -206,7 +228,6 @@ def __init__( self, parent ): self.Centre( wx.BOTH ) # Connect Events - self.list_ctrl.Bind( wx.EVT_DROP_FILES, self.list_ctrl_on_drop_files ) self.list_ctrl.Bind( wx.EVT_LIST_ITEM_SELECTED, self.on_list_item_selected ) self.StartTimeCtrl.Bind( wx.EVT_TEXT, self.on_start_time_ctrl_text ) self.EndTimeCtrl.Bind( wx.EVT_TEXT, self.on_end_time_ctrl_text ) @@ -215,6 +236,7 @@ def __init__( self, parent ): self.MovUpBtn.Bind( wx.EVT_BUTTON, self.on_move_up_file_button_click ) self.MovDownBtn.Bind( wx.EVT_BUTTON, self.on_move_down_file_button_click ) self.ClearAllBtn.Bind( wx.EVT_BUTTON, self.on_clear_all_button_click ) + self.SizeControlMode.Bind( wx.EVT_CHOICE, self.on_size_control_mode_change ) self.ExportBtn.Bind( wx.EVT_BUTTON, self.on_export_button_click ) self.ProjectWebBtn.Bind( wx.EVT_BUTTON, self.on_open_project_website_button_click ) @@ -223,9 +245,6 @@ def __del__( self ): # Virtual event handlers, override them in your derived class - def list_ctrl_on_drop_files( self, event ): - event.Skip() - def on_list_item_selected( self, event ): event.Skip() @@ -250,6 +269,9 @@ def on_move_down_file_button_click( self, event ): def on_clear_all_button_click( self, event ): event.Skip() + def on_size_control_mode_change( self, event ): + event.Skip() + def on_export_button_click( self, event ): event.Skip() diff --git a/src/command.py b/src/command.py deleted file mode 100644 index 2791f75..0000000 --- a/src/command.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -命令生成 -""" - - -def concat_filter( - input: list[str], - output_video_name: str, - output_audio_name: str, - v: int = 1, - a: int = 1, -) -> str: - """ - 拼接滤镜。可以拼接多个视频流和音频流。 - - Args: - input ([str]): 输入的视频流和音频流 - output_video_name (str): 输出的视频流名称 - output_audio_name (str): 输出的音频流名称 - v (int, optional): 是否拼接视频流。Defaults to 1. - a (int, optional): 是否拼接音频流。Defaults to 1. - - Returns: - str: 生成的指令 - """ - inputs = [] - input_num = len(input) // 2 - - for i in input: - inputs.append(f"[{i}]") - - inputs = " ".join(inputs) - - return f"{inputs} concat=n={input_num}:v={v}:a={a} [{output_video_name}] [{output_audio_name}]" - - -def amix_filter(input: list[str], output: str) -> str: - """ - amix 滤镜。可以合并多个音轨 - - Args: - input (list[str]): 要合并的多个音频流 - output (str): 输出的音频流名称 - - Returns: - str: amix 滤镜命令 - """ - inputs = [] - input_number = len(input) - for i in input: - inputs.append(f"[{i}]") - inputs = " ".join(inputs) - - return f"{inputs} amix=inputs={input_number}[{output}]" - - -def merge_filestream_audio_channel( - input: str, audio_channel_number: int, output_audio_name: str -) -> str: - """ - 混合文件的多轨道 - Args: - input (str): 文件流 - audio_channel_number (int): 音频流数量 - output_audio_name (str): 输出音频流名称 - - Returns: - str: 生成的指令 - """ - inputs = [] - for i in range(audio_channel_number): - inputs.append(f"{input}:a:{i}") - - return amix_filter(inputs, output_audio_name) diff --git a/src/controller/core.py b/src/controller/core.py new file mode 100644 index 0000000..721a19a --- /dev/null +++ b/src/controller/core.py @@ -0,0 +1,175 @@ +import logging +import os +import threading +from typing import TYPE_CHECKING + +from pydantic import ValidationError +import wx + +from export.model import ExportConfig, ExportTask, VideoFile +import export +from message import ExportMessage, WorkStateEnum + +if TYPE_CHECKING: + from SimpleCutMainFrame import SimpleCutPyMainFrame + + +class CoreController: + """核心控制器""" + + def __init__(self, view: "SimpleCutPyMainFrame"): + self.view = view + self.first_select_index: int = 0 + self.task = ExportTask() + + self.working_thread = {} + + def get_file(self, index: int) -> VideoFile: + """获取文件 + + Args: + index (int): 文件索引 + + Returns: + VideoFile: 文件对象 + """ + return self.task.video_sequence[index] + + def set_file(self, index: int, video: VideoFile): + """设置文件 + + Args: + index (int): 文件索引 + video (VideoFile): 文件对象 + """ + self.task.video_sequence.modify(index, video) + + def add_file(self, filename: str, path: str): + """添加文件 + + Args: + filename (str): 文件名 + path (str): 文件路径 + """ + self.task.video_sequence.append( + VideoFile( + no=self.sequence_length() + 1, + file_name=filename, + file_path=path, + start_time="开始", + end_time="结束", + ) + ) + + def remove_file(self, index: int): + """删除文件 + + Args: + index (int): 文件索引 + """ + self.task.video_sequence.remove(index) + + def swap_file(self, index1: int, index2: int): + """交换文件 + + Args: + index1 (int): 文件索引1 + index2 (int): 文件索引2 + """ + self.task.video_sequence.swap(index1, index2) + # 更新编号 + self.task.video_sequence[index1].no, self.task.video_sequence[index2].no = ( + self.task.video_sequence[index2].no, + self.task.video_sequence[index1].no, + ) + + def clear_all_files(self): + """清除所有文件""" + self.task.video_sequence.clear() + + def sequence_length(self) -> int: + """获取视频序列长度 + + Returns: + int: 视频序列长度 + """ + return len(self.task.video_sequence.video_files) + + def setup_export_config(self, export_config: ExportConfig): + """设置导出配置 + + Args: + export_config (ExportConfig): 导出配置对象 + """ + self.task.export_config = export_config + + def export_sequence(self): + """导出视频序列""" + + # 验证 + try: + self.task = ExportTask.model_validate(self.task) + except ValidationError as e: + logging.error(f"Export Task Validate Error: {e}") + return + + # 预处理,将多导出化为单导出 + tasks = [] + if self.task.export_config.multi_track_mode == "export_both": + first_only_task = self.task.model_copy(deep=True) + first_only_task.export_config.multi_track_mode = "first" + + amix_task = self.task.model_copy(deep=True) + amix_task.export_config.multi_track_mode = "amix" + # 添加后缀 + header, ext = os.path.splitext(amix_task.get_export_full_path()) + amix_task.export_file_name = header + "_amix" + ext + + tasks.append(first_only_task) + tasks.append(amix_task) + else: + tasks.append(self.task) + + def export_thread(task): + """导出线程函数""" + success = export.core.export(task) + export_path = task.get_export_full_path() + + # 从工作线程字典中移除 + if export_path in self.working_thread: + self.working_thread.pop(export_path) + + # 使用wx.CallAfter在主线程中更新UI + if success: + wx.CallAfter( + self.view.on_export_done, + ExportMessage(WorkStateEnum.SUCCESS, "导出完成", export_path), + ) + else: + wx.CallAfter( + self.view.on_export_done, + ExportMessage(WorkStateEnum.FAIL, "导出失败", export_path), + ) + + for task in tasks: + export_path = task.get_export_full_path() + # 创建线程 + thread = threading.Thread(target=export_thread, args=(task,)) + # 保存线程引用 + self.working_thread[export_path] = thread + # 启动线程 + thread.start() + + @staticmethod + def format_time(time_str: str) -> str: + """格式化时间字符串 + + Args: + time_str (str): 时间字符串,要求只能由数字和冒号组成,或为特殊值"开始"/"结束" + + Returns: + str: 格式化后的时间字符串 + """ + + time_str = time_str.strip().replace(":", ":").replace(" ", ":") + return time_str diff --git a/src/export/__init__.py b/src/export/__init__.py new file mode 100644 index 0000000..3dfc23e --- /dev/null +++ b/src/export/__init__.py @@ -0,0 +1,3 @@ +from .core import * +from .model import * +from .filter_builder import * \ No newline at end of file diff --git a/src/export/core.py b/src/export/core.py new file mode 100644 index 0000000..8b92aa1 --- /dev/null +++ b/src/export/core.py @@ -0,0 +1,184 @@ +import logging +import subprocess +from pymediainfo import MediaInfo + +from tools.path import PathHelper + +from .model import ( + ExportConfig, + ExportTask, + MbpsConfig, + MultiTrackMode, + VideoFile, + X264Config, +) +from .filter_builder import FilterBuilder + + +def export(task: ExportTask) -> bool: + """导出视频切片 + + Args: + task (ExportTask): 导出任务 + + Returns: + bool: 导出是否成功 + """ + + ffmpeg_path = PathHelper.get_ffmpeg_path() + + # 构建命令 + command = build_command_header(ffmpeg_path) + + for video_file in task.video_sequence.get_video_list(): + command += build_video_input(video_file) + + try: + filter_complex = build_filter_complex( + task.video_sequence.get_video_list(), + task.export_config.multi_track_mode, + ) + except Exception as e: + logging.error("build filter complex error: {:?}", e) + return False + + command += filter_complex + + command += build_command_tail(task.get_export_full_path(), task.export_config) + + logging.info("导出命令: %s", command) + + # 执行命令 + try: + subprocess.run( + command, shell=False, check=True, creationflags=subprocess.CREATE_NO_WINDOW + ) + logging.info("export one success") + return True + except subprocess.CalledProcessError as e: + logging.error("export one failed with error: {:?}", e) + return False + + +def build_command_header(ffmpeg_path: str) -> str: + """构建ffmpeg命令头 + + Args: + ffmpeg_path (str): ffmpeg可执行文件路径 + + Returns: + str: 构建后的ffmpeg命令头字符串 + """ + return ffmpeg_path + " -y" + + +def build_command_header_without_executeable() -> str: + """构建不带执行文件路径的ffmpeg命令头 + + Returns: + str: 构建后的不带执行文件路径的ffmpeg命令头字符串 + """ + return " -y" + + +def build_video_input(video_file: VideoFile) -> str: + """构建视频输入参数 + + Args: + video_file (VideoFile): 视频文件信息 + + Returns: + str: 构建后的视频输入参数字符串 + """ + + start_time_string = ( + "" if video_file.start_time == "开始" else f" -ss {video_file.start_time}" + ) + end_time_string = ( + "" if video_file.end_time == "结束" else f" -to {video_file.end_time}" + ) + + ret = f'{start_time_string}{end_time_string} -i "{video_file.get_full_file_path()}"' + + return ret + + +def build_filter_complex( + video_files: list[VideoFile], audio_merge_type: MultiTrackMode +) -> str: + """构建滤镜复杂链 + + Args: + video_files (list[VideoFile]): 视频文件列表 + audio_merge_type (MultiTrackMode): 音频合并类型 + + Returns: + str: 构建后的滤镜复杂链字符串 + """ + + if not video_files: + return "" + + concat_inputs = [] + filter_builder = FilterBuilder() + + for i, v in enumerate(video_files): + concat_inputs.append(f"{i}:v") + + multi_track_mode = get_audio_track_count(v.get_full_file_path()) + if multi_track_mode > 1 and audio_merge_type == "amix": + # 多音轨且需要合并 + output_alias = f"{i}a" # 为每个音频流创建唯一别名 + + # 添加合并amix滤镜 + filter_builder.add_merge_amix_filter(i, multi_track_mode, output_alias) + + concat_inputs.append(output_alias) + else: + # 单音轨或不需要合并 + concat_inputs.append(f"{i}:a") + + # 在所有文件处理完毕后添加concat滤镜 + filter_builder.add_concat_filter(concat_inputs, "v", "a") + + filter_complex = filter_builder.build_to_string() + + if filter_complex: + filter_complex += " -map [v] -map [a]" + + return filter_complex + + +def build_command_tail(output_path: str, config: ExportConfig) -> str: + """构建命令尾 + + Args: + output_path (str): 导出路径 + config (ExportConfig): 导出配置 + + Returns: + str: 构建后的命令尾字符串 + """ + + match config.size_control: + case X264Config(): + return f' -c:v libx264 -crf 23.5 -preset veryslow -keyint_min 600 -g 600 -refs 4 -bf 3 -me_method umh -sc_threshold 60 -b_strategy 1 -qcomp 0.5 -psy-rd 0.3:0 -aq-mode 2 -aq-strength 0.8 -c:a aac -b:a 128k -movflags faststart "{output_path}"' + case MbpsConfig(): + return f' -b:v {config.size_control.mbps}M "{output_path}"' + case _: + return f' "{output_path}"' + + +# 工具函数 +def get_audio_track_count(file_path: str) -> int: + """MediaInfo 获取音频轨道数量 + + Args: + file_path (str): 视频文件路径 + + Returns: + int: 音频轨道数量 + """ + + media_info = MediaInfo.parse(file_path) + return len(media_info.audio_tracks) diff --git a/src/export/filter_builder.py b/src/export/filter_builder.py new file mode 100644 index 0000000..1706fec --- /dev/null +++ b/src/export/filter_builder.py @@ -0,0 +1,112 @@ +""" +滤镜构建器 + +用于构建复杂的滤镜字符串 +""" + + +class FilterBuilder: + """ + 滤镜构建器类,用于构建复杂的滤镜字符串 + """ + + def __init__(self): + """ + 创建一个新的滤镜构建器实例 + """ + self.filters = [] + + def filter_count(self) -> int: + """ + 获取滤镜数量 + + Returns: + int: 滤镜数量 + """ + return len(self.filters) + + def is_empty(self) -> bool: + """ + 检查是否有滤镜 + + Returns: + bool: 如果没有滤镜返回True,否则返回False + """ + return len(self.filters) == 0 + + def build_to_string(self) -> str: + """ + 构建滤镜字符串 + + Returns: + str: 包含所有滤镜的命令行片段。如果没有滤镜,返回空字符串 + """ + if self.is_empty(): + return "" + else: + return f' -filter_complex "{";".join(self.filters)}"' + + def add_merge_amix_filter( + self, input_index: int, track_count: int, output_alias: str + ): + """ + 添加合并amix滤镜 + + Args: + input_index (int): 输入索引,对应视频切片的索引 + track_count (int): 音频轨道数量 + output_alias (str): 输出别名,用于引用合并后的音频流 + """ + self.filters.append(build_amix_filter(input_index, track_count, output_alias)) + + def add_concat_filter( + self, inputs: list[str], video_output: str, audio_output: str + ): + """ + 添加concat滤镜 + + Args: + inputs (list[str]): 输入流列表,包含视频和音频流的索引 + video_output (str): 视频输出别名,用于引用拼接后的视频流 + audio_output (str): 音频输出别名,用于引用拼接后的音频流 + """ + self.filters.append(build_concat_filter(inputs, video_output, audio_output)) + + +def build_amix_filter(input_index: int, track_count: int, output_alias: str) -> str: + """ + 构建amix音频合并滤镜 + + Args: + input_index (int): 输入索引,对应视频切片的索引 + track_count (int): 音频轨道数量 + output_alias (str): 输出别名,用于引用合并后的音频流 + + Returns: + str: 包含amix滤镜的命令行片段 + """ + inputs = [] + for i in range(track_count): + inputs.append(f"[{input_index}:a:{i}]") + + return f"{' '.join(inputs)} amix=inputs={track_count}[{output_alias}]" + + +def build_concat_filter(inputs: list[str], video_output: str, audio_output: str) -> str: + """ + 构建concat拼接滤镜 + + Args: + inputs (list[str]): 输入流列表,包含视频和音频流的索引 + video_output (str): 视频输出别名,用于引用拼接后的视频流 + audio_output (str): 音频输出别名,用于引用拼接后的音频流 + + Returns: + str: 包含concat滤镜的命令行片段 + """ + input_count = len(inputs) // 2 + inputs_str = " ".join(f"[{s}]" for s in inputs) + + return ( + f"{inputs_str} concat=n={input_count}:v=1:a=1 [{video_output}] [{audio_output}]" + ) diff --git a/src/export/model.py b/src/export/model.py new file mode 100644 index 0000000..2666435 --- /dev/null +++ b/src/export/model.py @@ -0,0 +1,184 @@ +"""导出模型""" + +import os +import re +import time +from typing import Literal +from pydantic import BaseModel, field_validator + + +class VideoFile(BaseModel): + """视频文件""" + + no: int + file_name: str + file_path: str + start_time: str = "开始" + end_time: str = "结束" + + def get_full_file_path(self) -> str: + """获取视频文件完整路径 + + Returns: + str: 视频文件完整路径 + """ + return os.path.join(self.file_path, self.file_name) + + def set_full_file_path(self, full_file_path: str) -> None: + """设置视频文件完整路径 + + Args: + full_file_path (str): 视频文件完整路径 + """ + self.file_path, self.file_name = os.path.split(full_file_path) + + @field_validator("no") + def validate_no(cls, v: int) -> int: + """验证视频文件序号是否合法 + + Args: + v (int): 视频文件序号 + + Returns: + int: 验证通过的视频文件序号 + """ + if v < 0: + raise ValueError("视频文件序号必须为非负数") + return v + + @field_validator("start_time", "end_time") + def validate_time(cls, v: str) -> str: + """ + 验证时间字符串是否合法,并进行格式化 + + Args: + v (str): 时间字符串,要求只能由数字和冒号组成,或为特殊值"开始"/"结束" + + Returns: + str: 验证通过的时间字符串 + """ + + # 去除两端空格 + v = v.strip() + + # 全角替换为半角 + v = v.replace(":", ":") + + # 空格替换冒号 + v = v.replace(" ", ":") + + # 检查是否为特殊值或有效时间格式 + if v != "开始" and v != "结束" and not re.match(r"^[0123456789:]*$", v): + raise ValueError("时间格式错误") + + return v + + @field_validator("file_path") + def validate_none_path(cls, v: str) -> str: + """验证文件路径是否为空 + + Args: + v (str): 文件路径 + + Returns: + str: 验证通过的文件路径 + """ + if v == "": + raise ValueError("文件路径不能为空") + return v + + +class VideoSequence(BaseModel): + """视频序列""" + + video_files: list[VideoFile] = [] + + def __getitem__(self, item): + return self.video_files[item] + + def __len__(self): + return len(self.video_files) + + def get_video_list(self) -> list[VideoFile]: + return self.video_files + + def modify(self, no: int, video: VideoFile): + self.video_files[no] = video + + def remove(self, no: int): + # 移除 + self.video_files.pop(no) + + # 排序 + self.video_files.sort(key=lambda x: x.no) + + # 重新编号 + for i, v in enumerate(self.video_files): + v.no = i + 1 + + def insert(self, video: VideoFile, no: int): + self.video_files.insert(no, video) + + def append(self, video: VideoFile): + self.video_files.append(video) + + def swap(self, no1: int, no2: int): + self.video_files[no1], self.video_files[no2] = ( + self.video_files[no2], + self.video_files[no1], + ) + + def clear(self): + self.video_files = [] + + +type MultiTrackMode = Literal["first", "amix", "export_both"] +type SizeControlMode = X264Config | MbpsConfig | None + + +class X264Config(BaseModel): + pass + + +class MbpsConfig(BaseModel): + """Mbps 配置""" + + mbps: float = 6 + + +class ExportConfig(BaseModel): + """导出配置""" + + multi_track_mode: MultiTrackMode = "first" + size_control: SizeControlMode = MbpsConfig() + + +class ExportTask(BaseModel): + """导出任务""" + + video_sequence: VideoSequence = VideoSequence() + export_file_name: str = "" + export_file_path: str = "" + export_config: ExportConfig = ExportConfig() + + def get_export_full_path(self) -> str: + """获取导出文件完整路径 + + Returns: + str: 导出文件完整路径 + """ + # 如果 path 为空则使用第一个视频文件的路径 + export_file_path = ( + self.export_file_path + if self.export_file_path != "" + else self.video_sequence[0].file_path + ) + export_file_name = self.export_file_name + if export_file_name == "": + export_file_name = time.strftime("No Title %Y.%m.%d - %H.%M.output.mp4") + + # 自动补全后缀扩展 + if "." not in export_file_name: + export_file_name += ".mp4" + + return os.path.join(export_file_path, export_file_name) diff --git a/src/main.py b/src/main.py index df88ab1..b2394ac 100644 --- a/src/main.py +++ b/src/main.py @@ -10,6 +10,7 @@ # 配置日志记录 logging.basicConfig( level=logging.DEBUG, + encoding="utf-8", format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", filename="SimpleCut.log", filemode="w", diff --git a/src/model.py b/src/model.py deleted file mode 100644 index 0a4bb30..0000000 --- a/src/model.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -数据模型 -""" - - -class VideoModel: - path: str - filename: str - _start_time: str = '' - _end_time: str = '' - - def __init__(self, path: str, filename: str, start_time: str = '', end_time: str = ''): - self.path = path - self.filename = filename - self.start_time = start_time - self.end_time = end_time - - @property - def start_time(self) -> str: - return self._start_time - - @start_time.setter - def start_time(self, time_string: str): - self._start_time = VideoModel.format_time(time_string) - - @property - def end_time(self) -> str: - return self._end_time - - @end_time.setter - def end_time(self, time_string: str): - self._end_time = VideoModel.format_time(time_string) - - @staticmethod - def format_time(time_string: str) -> str: - # 一些提升体验的小更改 - # 除去两侧空格 - time_string = time_string.strip() - - # 将空格替换为 ":" - # 将全角 “:” 替换为半角 “:” - time_string = str.replace(time_string, " ", ":") - time_string = str.replace(time_string, ":", ":") - - return time_string - - -class VideoSequenceModel: - """ - 视频序列模型 - """ - video_list: list[VideoModel] - - def __init__(self): - self.video_list = [] - - def __getitem__(self, item): - return self.video_list[item] - - def __iter__(self): - return iter(self.video_list) - - def __len__(self): - return len(self.video_list) - - def pop_video(self, no: int): - self.video_list.pop(no) - - def insert_video(self, video: VideoModel, no: int): - self.video_list.insert(no, video) - - def append_video(self, video: VideoModel): - self.video_list.append(video) - - def swap_item(self, no1: int, no2: int): - self.video_list[no1], self.video_list[no2] = self.video_list[no2], self.video_list[no1] - - def clear_all(self): - self.video_list = [] diff --git a/src/tools/path.py b/src/tools/path.py new file mode 100644 index 0000000..976a522 --- /dev/null +++ b/src/tools/path.py @@ -0,0 +1,31 @@ +import os +import sys +from typing import Optional + + +class PathHelper: + """路径助手""" + + ffmpeg_path: Optional[str] = None + + @staticmethod + def get_ffmpeg_path() -> str: + """ + 获取ffmpeg可执行文件的路径 + + Returns: + str: ffmpeg可执行文件的完整路径 + """ + if PathHelper.ffmpeg_path: + return PathHelper.ffmpeg_path + + if getattr(sys, "frozen", False): + # 打包后环境 + base_path = sys._MEIPASS # type: ignore + else: + # 开发环境 + base_path = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + + return os.path.join(base_path, "assets", "ffmpeg.exe") diff --git a/uv.lock b/uv.lock index 6f53970..c0f7770 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "logging" version = "0.4.9.6" @@ -47,6 +56,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + [[package]] name = "pyinstaller" version = "6.17.0" @@ -125,6 +178,7 @@ version = "0.4.3" source = { virtual = "." } dependencies = [ { name = "logging" }, + { name = "pydantic" }, { name = "pymediainfo" }, { name = "wxpython" }, ] @@ -137,6 +191,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "logging", specifier = ">=0.4.9.6" }, + { name = "pydantic", specifier = ">=2.12.5" }, { name = "pymediainfo", specifier = ">=7.0.1" }, { name = "wxpython", specifier = ">=4.2.2" }, ] @@ -144,6 +199,27 @@ requires-dist = [ [package.metadata.requires-dev] dev = [{ name = "pyinstaller", specifier = ">=6.17.0" }] +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "wxpython" version = "4.2.4"