diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..13adc87
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 心水湛清
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e220044
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+# midi2exo
+
+本工具是一个适用于 [AviUtl](http://spring-fragrance.mints.ne.jp/aviutl/) 的 音MAD 辅助对轨工具,可根据 midi 文件中的音符将视频片段按音符节奏对好,并以 exo 文件的形式导出使用。
+
+目前仅支持 AviUtl 中文版 1.16d。
diff --git a/midi2exo_main.py b/midi2exo_main.py
new file mode 100644
index 0000000..786fa14
--- /dev/null
+++ b/midi2exo_main.py
@@ -0,0 +1,438 @@
+import copy
+import math
+import mido
+import os
+import sys
+import PyQt5
+from PyQt5 import QtCore, QtWidgets
+from PyQt5.QtWidgets import QAction, QApplication, QCheckBox, QFileDialog, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, QProgressDialog, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
+from PyQt5.QtGui import QBrush, QColor, QIcon, QTextFormat
+from PyQt5.Qt import Qt, QIntValidator
+from PyQt5.QtCore import QEvent, QObject
+from pyaviutl.exo import *
+
+version = '1.0a'
+
+illegalChars = dict((ord(char), None) for char in '\/*?:"<>|')
+exts = ['mp4', 'ts', 'wmv', 'mov', 'mkv', 'avi']
+def toFileName(s):
+ return s.translate(illegalChars)
+
+class DropFileHandler(QObject):
+ def eventFilter(self, watched, event):
+ if event.type() == QEvent.DragEnter:
+ event.accept()
+ elif event.type() == QEvent.Drop:
+ mime = event.mimeData()
+ if mime.hasUrls():
+ watched.setText(os.path.normpath(event.mimeData().urls()[0].toLocalFile()))
+ return True
+ return super().eventFilter(watched, event)
+class MenuItem:
+ def __init__(self, parent, name, tip=None, target=None, shortcut=None):
+ self.name = name
+ self.action = QAction(name, parent)
+ if shortcut is not None:
+ self.action.setShortcut(shortcut)
+ if tip is not None:
+ self.action.setStatusTip(tip)
+ if target is not None:
+ self.action.triggered.connect(target)
+class Channel:
+ def __init__(self, name, items, path, alpha, flip):
+ self.items = items
+ self.name = name
+ self.auto = True
+ self.path = path
+ self.alpha = alpha
+ self.flip = flip
+ self.exists = False
+ self.enabled = True
+ def clearAuto(self):
+ self.auto = False
+ def size(self):
+ return len(self.items)
+class Note:
+ def __init__(self, start=1, layer=1, objid=1):
+ self.start, self.layer, self.objid = start, layer, objid
+
+class Midi2ExoMain(QMainWindow):
+ titlePref = 'midi2exo v{0}'.format(version)
+ nowChl = -1
+ def __init__(self):
+ global app
+ super().__init__()
+ self.menu = {
+ '文件': [
+ MenuItem(self, '打开', '打开 MIDI 文件', self.open, 'Ctrl+O'),
+ MenuItem(self, '导出 EXO', '导出 EXO 文件', self.save, 'Ctrl+S'),
+ MenuItem(self, '|'),
+ MenuItem(self, '退出', '退出本应用程序', self.close, 'Alt+F4')
+ ],
+ '帮助': [MenuItem(self, '关于', '关于本程序', self.about)]
+ }
+ self.file = ''
+ self.channels = []
+ self.setAcceptDrops(True)
+ app.focusChanged.connect(self.onFocusChanged)
+ self.render()
+ def dragEnterEvent(self, event):
+ if event.mimeData().hasUrls():
+ event.accept()
+ else:
+ event.ignore()
+ def dropEvent(self, event):
+ files = [u.toLocalFile() for u in event.mimeData().urls()]
+ if len(files) != 0:
+ self.file = files[0]
+ self.handleMidi()
+ def about(self):
+ msgBox = QMessageBox(self)
+ msgBox.setIcon(QMessageBox.Information)
+ msgBox.setWindowTitle('关于')
+ msgBox.setText('''
+
+ midi2exo
+
+ 版本:v{0}
+ 作者:xszqxszq
+
+ '''.format(version))
+ msgBox.setTextFormat(Qt.RichText)
+ msgBox.setInformativeText('本程序可以利用 MIDI 文件生成 exo 文件,以减轻在 AviUtl 中音乐相关剪辑(如音MAD)的工作量。')
+ aboutQt = msgBox.addButton('关于 Qt', QMessageBox.ActionRole)
+ msgBox.addButton(QMessageBox.Ok)
+ msgBox.exec_()
+ if msgBox.clickedButton() == aboutQt:
+ QMessageBox.aboutQt(self, '关于 Qt')
+ def open(self):
+ self.file, _ = QFileDialog.getOpenFileName(self, '打开 MIDI 文件', filter='MIDI 文件 (*.mid)')
+ if self.file:
+ self.handleMidi()
+ def setDefSrcPath(self):
+ path = QFileDialog.getExistingDirectory(self, '选择素材默认存放文件夹', self.defSrcPathLE.text())
+ if path != '':
+ self.defSrcPathLE.setText(os.path.normpath(path))
+ def setNowSrcPath(self):
+ path, _ = QFileDialog.getOpenFileName(self, '选择素材文件', self.nowSrcPathLE.text())
+ if path != '':
+ self.nowSrcPathLE.setText(os.path.normpath(path))
+ def handleMidi(self):
+ try:
+ self.midi = mido.MidiFile(self.file)
+ except Exception as e:
+ QMessageBox.critical(self, '错误', '文件无法读取,该文件可能不是midi文件')
+ return
+ self.channels = []
+ nowLayer, nowTempo = 0, 500000
+ for track in self.midi.tracks:
+ initial = True
+ nowPosition = 0
+ lastNote = None
+ for msg in track:
+ nowPosition += msg.time
+ if msg.type == 'set_tempo':
+ nowTempo = msg.tempo
+ elif msg.type == 'note_on':
+ if initial:
+ nowLayer += 1
+ self.channels.append(Channel(track.name, [], self.getPath(self.defSrcPathLE.text(), track.name, self.extLE.text()), self.defAlpha.checkState(), self.defFlip.checkState()))
+ initial = False
+ if lastNote and lastNote.start == nowPosition:
+ continue
+ if lastNote:
+ self.channels[lastNote.objid].items.append(ExoVideo(
+ start = mido.tick2second(lastNote.start, self.midi.ticks_per_beat, nowTempo),
+ end = mido.tick2second(nowPosition, self.midi.ticks_per_beat, nowTempo),
+ layer = lastNote.layer
+ ))
+ lastNote = Note(nowPosition, nowLayer, nowLayer-1)
+ if lastNote is not None:
+ self.channels[lastNote.objid].items.append(ExoVideo(
+ start = mido.tick2second(lastNote.start, self.midi.ticks_per_beat, nowTempo),
+ end = mido.tick2second(nowPosition, self.midi.ticks_per_beat, nowTempo),
+ layer = lastNote.layer
+ ))
+ self.nowChl = -1
+ self.refresh()
+ self.setWindowTitle('{0} - {1}'.format(self.titlePref, self.file))
+ self.chlLstWid.clearSelection()
+ def anyNonExist(self):
+ for i in self.channels:
+ if i.enabled and not i.exists:
+ return True
+ return False
+ def save(self):
+ self.refresh()
+ if self.anyNonExist():
+ reply = QMessageBox.warning(self, '警告', '有轨道的素材文件不存在,这可能导致exo文件在导入时会不断报错,是否仍要导出?', QMessageBox.Yes | QMessageBox.No)
+ if reply == QMessageBox.No:
+ return
+ path, _ = QFileDialog.getSaveFileName(self, '导出 EXO 文件', filter='EXO 文件 (*.exo)')
+ if not path:
+ return
+ exo = {}
+ exo['exedit'] = {
+ 'width': int(self.geomWLE.text()),
+ 'height': int(self.geomHLE.text()),
+ 'rate': int(self.fps.text()),
+ 'scale': 1,
+ 'audio_rate': int(self.sr.text()),
+ 'audio_ch': 2,
+ }
+ exo['exedit']['length'] = math.ceil(self.midi.length * exo['exedit']['rate']) # !Important: Length must be calculated
+ nowObj = 0
+ for ch in self.channels:
+ if not ch.enabled:
+ continue
+ vid = SceneSettings(ch.path, alpha=ch.alpha//2)
+ for v in ch.items:
+ exo[nowObj] = copy.deepcopy(v)
+ exo[nowObj].group = nowObj + 1
+ exo[nowObj]['sceneSettings'] = vid
+ exo[nowObj]['effects'][0]['左右翻转'] = ch.flip // 2 * (nowObj % 2)
+ exo[nowObj]['start'], exo[nowObj]['end'] = 1 + math.ceil(v['start'] * exo['exedit']['rate']), math.ceil(v['end'] * exo['exedit']['rate'])
+ nowObj += 1
+ if nowObj != 0:
+ exo[nowObj-1]['end'] = exo['exedit']['length']
+ with open(path, 'w', encoding='GBK') as f:
+ for key, value in exo.items():
+ f.write('[{0}]\n'.format(key))
+ if type(value) == ExoVideo:
+ attid = 1
+ for ikey, ival in value.items():
+ if ikey == 'sceneSettings':
+ f.write('[{0}.0]\n'.format(key))
+ for skey, sval in ival.items():
+ if skey == 'scene':
+ f.write('={0}\n'.format(sval))
+ else:
+ f.write('{0}={1}\n'.format(skey, sval))
+ elif ikey == 'effects':
+ for e in ival:
+ f.write('[{0}.{1}]\n'.format(key, attid))
+ for akey, aval in e.items():
+ f.write('{0}={1}\n'.format(akey, aval))
+ attid += 1
+ else:
+ f.write('{0}={1}\n'.format(ikey, ival))
+ else:
+ for ikey, ival in value.items():
+ f.write('{0}={1}\n'.format(ikey, ival))
+ QMessageBox.information(self, '导出完毕', 'EXO 文件已成功导出。')
+ def getPath(self, prvPath, prefix, default):
+ global exts
+ for i in [default, *exts]:
+ nowPath = os.path.normpath(prvPath + '/' + toFileName(prefix + '.' + i))
+ if os.path.exists(nowPath):
+ return nowPath
+ return os.path.normpath(prvPath + '/' + toFileName(prefix + '.' + default))
+ def refresh(self):
+ for i in self.channels:
+ if i.auto:
+ i.path = self.getPath(self.defSrcPathLE.text(), i.name, self.extLE.text())
+ i.exists = os.path.exists(i.path)
+ self.renderList()
+ def renderList(self):
+ self.chlLstWid.clear()
+ for index, i in enumerate(self.channels):
+ item = QTreeWidgetItem(self.chlLstWid)
+ if not i.enabled:
+ item.setText(0, '已禁用')
+ item.setForeground(0, QBrush(QColor('#C0C0C0')))
+ elif i.exists:
+ item.setText(0, '正常')
+ item.setForeground(0, QBrush(QColor('#009900')))
+ else:
+ item.setText(0, '文件不存在')
+ item.setForeground(0, QBrush(Qt.red))
+ item.setText(1, str(index))
+ item.setText(2, i.name)
+ item.setText(3, str(i.size()))
+ item.setText(4, i.path)
+ i.item = item
+ self.chlLstWid.addTopLevelItem(item)
+ for i in range(5):
+ self.chlLstWid.resizeColumnToContents(i)
+ self.propGrp.setEnabled(self.nowChl != -1)
+ def render(self):
+ # Basic window properties
+ self.setWindowTitle(self.titlePref)
+ # Show status bar
+ self.statusBar()
+ # Show menu
+ menuBar = self.menuBar()
+ for name, column in self.menu.items():
+ menu = menuBar.addMenu('&' + name)
+ for i in column:
+ if i.name == '|':
+ menu.addSeparator()
+ else:
+ menu.addAction(i.action)
+ # Show workspace
+ prjPropGrp = QGroupBox('工程设置', self)
+ prjPropGrpLyt = QVBoxLayout()
+ geomLyt = QHBoxLayout()
+ self.geomWLE = QLineEdit(prjPropGrp)
+ self.geomHLE = QLineEdit(prjPropGrp)
+ self.fps = QLineEdit(prjPropGrp)
+ self.sr = QLineEdit(prjPropGrp)
+ self.extLE = QLineEdit(prjPropGrp)
+ self.geomWLE.setText('1920')
+ self.geomHLE.setText('1080')
+ self.fps.setText('60')
+ self.sr.setText('48000')
+ self.extLE.setText('mp4')
+ self.geomWLE.setValidator(QIntValidator(1, 100000, self))
+ self.geomHLE.setValidator(QIntValidator(1, 100000, self))
+ self.fps.setValidator(QIntValidator(1, 10000, self))
+ self.sr.setValidator(QIntValidator(0, 22579200, self))
+ self.extLE.setValidator(PyQt5.Qt.QRegularExpressionValidator(PyQt5.Qt.QRegularExpression('^[a-zA-Z0-9]*$')))
+ self.geomWLE.setFixedWidth(40)
+ self.geomHLE.setFixedWidth(40)
+ self.fps.setFixedWidth(40)
+ self.sr.setFixedWidth(40)
+ self.extLE.setFixedWidth(40)
+ geomLyt.addWidget(QLabel('图像大小'))
+ geomLyt.addWidget(self.geomWLE)
+ geomLyt.addWidget(QLabel('×'))
+ geomLyt.addWidget(self.geomHLE)
+ geomLyt.addStretch()
+ geomLyt.addWidget(QLabel('帧速率'))
+ geomLyt.addWidget(self.fps)
+ geomLyt.addStretch()
+ geomLyt.addWidget(QLabel('音频采样率'))
+ geomLyt.addWidget(self.sr)
+ srcLyt = QHBoxLayout()
+ srcLyt.addWidget(QLabel('首选素材扩展名'))
+ srcLyt.addWidget(self.extLE)
+ srcLyt.addStretch()
+ defSrcSel = QHBoxLayout()
+ defSrcSel.addWidget(QLabel('默认素材位置'))
+ self.defSrcPathLE = QLineEdit(prjPropGrp)
+ self.defSrcPathLE.setText(os.path.normpath(os.path.expanduser("~/Desktop")))
+ self.defSrcPathLE.installEventFilter(DropFileHandler(self))
+ defSrcSel.addWidget(self.defSrcPathLE)
+ defSrcSelBtn = QPushButton('...')
+ defSrcSelBtn.clicked.connect(self.setDefSrcPath)
+ defSrcSel.addWidget(defSrcSelBtn)
+ defChkLyt = QHBoxLayout()
+ self.defAlpha = QCheckBox('默认导入Alpha通道', prjPropGrp)
+ self.defFlip = QCheckBox('默认启用左右翻转', prjPropGrp)
+ self.defFlip.setCheckState(2)
+ defApplyBtn = QPushButton('应用')
+ defApplyBtn.clicked.connect(self.apply)
+ defChkLyt.addWidget(self.defAlpha)
+ defChkLyt.addWidget(self.defFlip)
+ defChkLyt.addStretch()
+ defChkLyt.addWidget(defApplyBtn)
+ prjPropGrpLyt.addLayout(defSrcSel)
+ prjPropGrpLyt.addLayout(geomLyt)
+ prjPropGrpLyt.addLayout(srcLyt)
+ prjPropGrpLyt.addLayout(defChkLyt)
+ prjPropGrp.setLayout(prjPropGrpLyt)
+
+ lstGrp = QGroupBox('轨道列表', self)
+ lstGrpLyt = QVBoxLayout()
+ self.chlLstWid = QTreeWidget(self)
+ self.chlLstWid.setHeaderLabels(['状态', '编号', '音轨名称', '音符数', '素材路径'])
+ self.chlLstWid.setRootIsDecorated(False)
+ lstGrpLyt.addWidget(self.chlLstWid)
+ lstGrp.setLayout(lstGrpLyt)
+ self.chlLstWid.itemClicked.connect(self.onItemClicked)
+
+ self.propGrp = QGroupBox('轨道设置', self)
+ propGrpLyt = QVBoxLayout()
+ infoLyt = QHBoxLayout()
+ self.nowChlLE = QLineEdit(self.propGrp)
+ self.nowChlLE.setReadOnly(True)
+ infoLyt.addWidget(QLabel('音轨名称'))
+ infoLyt.addWidget(self.nowChlLE)
+ infoLyt.addStretch()
+ self.nowState = QCheckBox('启用轨道')
+ self.nowState.stateChanged.connect(self.onStateChanged)
+ srcSel = QHBoxLayout()
+ srcSel.addWidget(QLabel('素材路径'))
+ self.nowSrcPathLE = QLineEdit(self.propGrp)
+ self.nowSrcPathLE.textChanged.connect(self.onPathChanged)
+ self.nowSrcPathLE.installEventFilter(DropFileHandler(self))
+ srcSel.addWidget(self.nowSrcPathLE)
+ srcSelBtn = QPushButton('...')
+ srcSelBtn.clicked.connect(self.setNowSrcPath)
+ srcSel.addWidget(srcSelBtn)
+ chkLyt = QHBoxLayout()
+ self.nowAlpha = QCheckBox('导入Alpha通道', self.propGrp)
+ self.nowAlpha.stateChanged.connect(self.onAlphaChanged)
+ self.nowFlip = QCheckBox('启用左右翻转', self.propGrp)
+ self.nowFlip.stateChanged.connect(self.onFlipChanged)
+ chkLyt.addWidget(self.nowAlpha)
+ chkLyt.addWidget(self.nowFlip)
+ propGrpLyt.addLayout(infoLyt)
+ propGrpLyt.addWidget(self.nowState)
+ propGrpLyt.addLayout(srcSel)
+ propGrpLyt.addLayout(chkLyt)
+ propGrpLyt.addStretch()
+ self.propGrp.setLayout(propGrpLyt)
+
+ chlUtls = QHBoxLayout()
+ chlUtls.addWidget(lstGrp)
+ chlUtls.addWidget(self.propGrp)
+
+ mainLyt = QVBoxLayout()
+ mainLyt.addWidget(prjPropGrp)
+ mainLyt.addLayout(chlUtls)
+ wid = QWidget(self)
+ self.setCentralWidget(wid)
+ wid.setLayout(mainLyt)
+
+ self.renderList()
+ self.show()
+
+ def apply(self):
+ for i in self.channels:
+ if i.auto:
+ i.alpha = self.defAlpha.checkState()
+ i.flip = self.defFlip.checkState()
+ self.refresh()
+ @QtCore.pyqtSlot()
+ def onFocusChanged(self):
+ self.refresh()
+ @QtCore.pyqtSlot(QtWidgets.QTreeWidgetItem, int)
+ def onItemClicked(self, it, col):
+ self.nowChl = int(it.text(1))
+ self.propGrp.setEnabled(True)
+ self.nowItem = it
+ self.nowChlLE.setText(self.channels[self.nowChl].name)
+ self.nowSrcPathLE.setText(self.channels[self.nowChl].path)
+ self.nowAlpha.setCheckState(self.channels[self.nowChl].alpha)
+ self.nowFlip.setCheckState(self.channels[self.nowChl].flip)
+ self.nowState.setCheckState(self.channels[self.nowChl].enabled * 2)
+ @QtCore.pyqtSlot(str)
+ def onPathChanged(self, new):
+ if self.nowChl == -1 or self.channels[self.nowChl].path == new:
+ return
+ self.channels[self.nowChl].path = new
+ self.channels[self.nowChl].clearAuto()
+ self.refresh()
+ @QtCore.pyqtSlot(int)
+ def onAlphaChanged(self, new):
+ if self.nowChl == -1 or self.channels[self.nowChl].alpha == new:
+ return
+ self.channels[self.nowChl].alpha = new
+ self.channels[self.nowChl].clearAuto()
+ @QtCore.pyqtSlot(int)
+ def onFlipChanged(self, new):
+ if self.nowChl == -1 or self.channels[self.nowChl].flip == new:
+ return
+ self.channels[self.nowChl].flip = new
+ self.channels[self.nowChl].clearAuto()
+ @QtCore.pyqtSlot(int)
+ def onStateChanged(self, new):
+ if self.nowChl == -1:
+ return
+ self.channels[self.nowChl].enabled = new == 2
+ self.refresh()
+if __name__ == '__main__':
+ app = QApplication(sys.argv)
+ ex = Midi2ExoMain()
+ sys.exit(app.exec_())
\ No newline at end of file
diff --git a/pyaviutl/exo.py b/pyaviutl/exo.py
new file mode 100644
index 0000000..5e34173
--- /dev/null
+++ b/pyaviutl/exo.py
@@ -0,0 +1,41 @@
+class ExoVideo(dict):
+ def __init__(self, start=1, end=2, layer=1, group=1, overlay=1, camera=0, video={}, flip=0):
+ dict.__init__(self, {
+ 'start': start,
+ 'end': end,
+ 'layer': layer,
+ 'group': group,
+ 'overlay': overlay,
+ 'camera': camera,
+ 'sceneSettings': video,
+ 'effects': [
+ {
+ '_name': '反转',
+ '上下翻转': 0,
+ '左右翻转': flip,
+ '亮度反转': 0,
+ '色相反转': 0,
+ '透明度反转': 0
+ },
+ {
+ '_name': '标准变换',
+ 'X': 0.0,
+ 'Y': 0.0,
+ 'Z': 0.0,
+ '缩放率': 100.00,
+ '透明度': 0.0,
+ '旋转': 0.00,
+ 'blend': 0,
+ }
+ ]
+ })
+class SceneSettings(dict):
+ def __init__(self, file, alpha=0, playback=1, vplay=100.0, loop=0):
+ dict.__init__(self, {
+ '_name': '视频文件',
+ '播放位置': playback,
+ '播放速度': vplay,
+ '循环播放': loop,
+ '读取Alpha通道': alpha,
+ 'file': file,
+ })
\ No newline at end of file