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 @@
@@ -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"