├── ykl_ui ├── __init__.py ├── welcome_dialog.py ├── create_pro_dialog.py ├── project_edit_dialog.py ├── import_audio_dialog.py ├── script_edit_dialog.py ├── layout_panel.py ├── sozai_panel.py ├── scenario_panel.py ├── roll_copy_dialog.py ├── sozai_edit_dialog.py ├── layout_edit_dialog.py └── scene_edit_dialog.py ├── ykl_core ├── __init__.py ├── ykl_appsetting.py ├── ykl_project.py ├── ykl_script.py ├── ykl_context.py ├── scene_block.py └── chara_sozai.py ├── media_utility ├── __init__.py ├── audio_tool.py ├── image_tool.py └── video_tool.py ├── ykl_app_setting └── app_setting.json ├── AppIcon.icns ├── images └── icon.png ├── .gitmodules ├── .gitignore ├── pyproject.toml ├── LICENSE.txt ├── YukkuLips.spec ├── README.md └── YukkuLips.py /ykl_ui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ykl_core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media_utility/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ykl_app_setting/app_setting.json: -------------------------------------------------------------------------------- 1 | { 2 | "CopyDialog Next Check": true 3 | } -------------------------------------------------------------------------------- /AppIcon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PickledChair/YukkuLips/HEAD/AppIcon.icns -------------------------------------------------------------------------------- /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PickledChair/YukkuLips/HEAD/images/icon.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "FFmpeg"] 2 | path = FFmpeg 3 | url = https://github.com/FFmpeg/FFmpeg.git 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #Directory 2 | __pycache__/ 3 | build/ 4 | dist/ 5 | pyinstaller-hooks/ 6 | .idea/ 7 | 8 | #File 9 | *.py[cod] 10 | *.DS_Store 11 | 12 | ### VirtualEnv ### 13 | .Python 14 | [Bb]in 15 | [Ll]ib 16 | [Ll]ib64 17 | [Ll]ocal 18 | [Ss]cripts 19 | pyvenv.cfg 20 | venv 21 | pip-selfcheck.json 22 | 23 | # End of Virtualenv 24 | 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "YukkuLips" 3 | version = "0.2.5" 4 | description = "The application to support creating chara-sozai videos." 5 | authors = ["PickledChair "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "3.9" 10 | numpy = "^1.21.3" 11 | Pillow = "^9.0.0" 12 | PySoundFile = "0.9.0.post1" 13 | wxPython = "4.1.1" 14 | pyinstaller = "^4.2" 15 | 16 | [tool.poetry.dev-dependencies] 17 | 18 | [build-system] 19 | requires = ["poetry-core>=1.0.0"] 20 | build-backend = "poetry.core.masonry.api" 21 | 22 | [tool.poetry.scripts] 23 | yklapp = "YukkuLips:main" 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 - 2020 SuitCase 4 | 5 | Permission is hereby granted, free of charge, to any 6 | person obtaining a copy of this software and associated 7 | documentation files (the "Software"), to deal in the 8 | Software without restriction, including without 9 | limitation the rights to use, copy, modify, merge, 10 | publish, distribute, sublicense, and/or sell copies of 11 | the Software, and to permit persons to whom the Software 12 | is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice 16 | shall be included in all copies or substantial portions 17 | of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 20 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 21 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 22 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 23 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 24 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 25 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 26 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 27 | DEALINGS IN THE SOFTWARE. 28 | -------------------------------------------------------------------------------- /ykl_core/ykl_appsetting.py: -------------------------------------------------------------------------------- 1 | """ 2 | yukkulips 3 | 4 | copyright (c) 2018 - 2020 suitcase 5 | 6 | this software is released under the mit license. 7 | https://github.com/pickledchair/yukkulips/blob/master/license.txt 8 | """ 9 | 10 | from pathlib import Path 11 | import json 12 | import sys 13 | 14 | class YKLAppSetting: 15 | def __init__(self): 16 | self.setting_path = Path.cwd() / "ykl_app_setting" / "app_setting.json" 17 | if getattr(sys, 'frozen', False): 18 | # frozen は PyInstaller でこのスクリプトが固められた時に sys に追加される属性 19 | # frozen が見つからない時は素の Python で実行している時なので False を返す 20 | bundle_dir = sys._MEIPASS 21 | self.setting_path = Path(bundle_dir) / "setting" / "app_setting.json" 22 | self.setting_dict = { 23 | "CopyDialog Next Check": True, 24 | } 25 | if not self.setting_path.exists(): 26 | self.save() 27 | 28 | def load(self): 29 | with self.setting_path.open('r') as f: 30 | self.setting_dict = json.load(f) 31 | 32 | def save(self): 33 | with self.setting_path.open('w') as f: 34 | json.dump(self.setting_dict, f, indent=4, ensure_ascii=False) 35 | -------------------------------------------------------------------------------- /ykl_ui/welcome_dialog.py: -------------------------------------------------------------------------------- 1 | """ 2 | YukkuLips 3 | 4 | Copyright (c) 2018 - 2020 SuitCase 5 | 6 | This software is released under the MIT License. 7 | https://github.com/PickledChair/YukkuLips/blob/master/LICENSE.txt 8 | """ 9 | 10 | import wx 11 | 12 | class YKLWelcomeDialog(wx.Dialog): 13 | def __init__(self, parent, ids, img_dir): 14 | super().__init__(parent, title='YukkuLipsへようこそ!', size=(500, 320)) 15 | 16 | panel = wx.Panel(self, wx.ID_ANY) 17 | box = wx.BoxSizer(wx.VERTICAL) 18 | bmp = wx.Bitmap() 19 | bmp.LoadFile(str(img_dir / "icon.png")) 20 | sbmp = wx.StaticBitmap(self, wx.ID_ANY, bmp) 21 | box.Add(sbmp, flag=wx.ALIGN_CENTER) 22 | panel.SetSizer(box) 23 | 24 | btns = wx.BoxSizer(wx.HORIZONTAL) 25 | 26 | pre_btn = wx.Button(self, ids[0], '既存のプロジェクトを探す') 27 | btns.Add(pre_btn, flag=wx.ALL, border=5) 28 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=pre_btn.GetId()) 29 | # his_btn = wx.Button(self, ids[1], '履歴から開く') 30 | # btns.Add(his_btn, flag=wx.ALL, border=5) 31 | # self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=his_btn.GetId()) 32 | new_btn = wx.Button(self, ids[1], '新規作成') 33 | btns.Add(new_btn, flag=wx.ALL, border=5) 34 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=new_btn.GetId()) 35 | btns.Layout() 36 | 37 | vbox = wx.BoxSizer(wx.VERTICAL) 38 | vbox.Add(panel, 1, flag=wx.EXPAND | wx.ALL, border=5) 39 | vbox.Add(btns, 0, flag=wx.ALIGN_CENTRE) 40 | vbox.Layout() 41 | self.SetSizer(vbox) 42 | self.Centre() 43 | 44 | def OnBtnClick(self, event): 45 | self.EndModal(event.GetId()) 46 | -------------------------------------------------------------------------------- /YukkuLips.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | block_cipher = None 4 | 5 | 6 | a = Analysis(['YukkuLips.py'], 7 | pathex=['.'], 8 | binaries=[], 9 | datas=[("FFmpeg/ffmpeg", "FFmpeg"), 10 | ("ykl_app_setting/app_setting.json", "setting"), 11 | ("images/icon.png", "images"), 12 | ], 13 | hiddenimports=[], 14 | hookspath=[], 15 | runtime_hooks=[], 16 | excludes=[], 17 | win_no_prefer_redirects=False, 18 | win_private_assemblies=False, 19 | cipher=block_cipher, 20 | noarchive=False) 21 | pyz = PYZ(a.pure, a.zipped_data, 22 | cipher=block_cipher) 23 | exe = EXE(pyz, 24 | a.scripts, 25 | [], 26 | exclude_binaries=True, 27 | name='YukkuLips', 28 | debug=False, 29 | bootloader_ignore_signals=False, 30 | strip=False, 31 | upx=True, 32 | console=False ) 33 | coll = COLLECT(exe, 34 | a.binaries, 35 | a.zipfiles, 36 | a.datas, 37 | strip=False, 38 | upx=True, 39 | name='YukkuLips') 40 | app = BUNDLE(coll, 41 | name='YukkuLips.app', 42 | icon='AppIcon.icns', 43 | bundle_identifier=None, 44 | info_plist={ 45 | 'CFBundleShortVersionString': '0.2.5', 46 | 'NSHumanReadableCopyright': 'Copyright © 2018 - 2020, SuitCase\nAll rights reserved.', 47 | 'NSHighResolutionCapable': 'True', 48 | 'NSRequiresAquaSystemAppearance': 'No', 49 | }) 50 | -------------------------------------------------------------------------------- /ykl_ui/create_pro_dialog.py: -------------------------------------------------------------------------------- 1 | """ 2 | YukkuLips 3 | 4 | Copyright (c) 2018 - 2020 SuitCase 5 | 6 | This software is released under the MIT License. 7 | https://github.com/PickledChair/YukkuLips/blob/master/LICENSE.txt 8 | """ 9 | 10 | import wx 11 | from pathlib import Path 12 | 13 | class YKLCreateProjectDialog(wx.Dialog): 14 | def __init__(self, parent): 15 | super().__init__(parent, title="新規プロジェクト") 16 | vbox1 = wx.BoxSizer(wx.VERTICAL) 17 | flexgrid = wx.FlexGridSizer(rows=2, cols=2, gap=(0, 0)) 18 | 19 | proj_label = wx.StaticText(self, wx.ID_ANY, 'プロジェクト名:') 20 | flexgrid.Add(proj_label, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=5) 21 | self.proj_text = wx.TextCtrl(self, wx.ID_ANY) 22 | flexgrid.Add(self.proj_text, 1, flag=wx.EXPAND | wx.ALL | wx.ALIGN_CENTRE_VERTICAL, border=5) 23 | 24 | loc_label = wx.StaticText(self, wx.ID_ANY, '保存場所:') 25 | flexgrid.Add(loc_label, flag=wx.ALL | wx.ALIGN_CENTRE_VERTICAL, border=5) 26 | self.dir_ctrl = wx.DirPickerCtrl(self, wx.ID_ANY, message="場所を選択", style=wx.DIRP_SMALL | wx.DIRP_USE_TEXTCTRL) 27 | flexgrid.Add(self.dir_ctrl, 1, flag=wx.EXPAND | wx.ALL | wx.ALIGN_CENTRE_VERTICAL) 28 | vbox1.Add(flexgrid, 1, flag=wx.EXPAND | wx.ALL, border=5) 29 | 30 | btns = wx.StdDialogButtonSizer() 31 | ok_btn = wx.Button(self, wx.ID_OK) 32 | cancel_btn = wx.Button(self, wx.ID_CANCEL) 33 | btns.AddButton(ok_btn) 34 | btns.AddButton(cancel_btn) 35 | btns.Realize() 36 | vbox1.Add(btns, 0, flag=wx.EXPAND | wx.ALL, border=5) 37 | 38 | vbox1.SetSizeHints(self) 39 | 40 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick) 41 | 42 | self.SetSizer(vbox1) 43 | self.Centre() 44 | 45 | def OnBtnClick(self, event): 46 | if event.GetId() == wx.ID_OK: 47 | proj_name = self.proj_text.GetValue() 48 | dir_path = self.dir_ctrl.GetPath() 49 | proj_path = Path(dir_path) / proj_name 50 | if len(proj_name) == 0 or len(dir_path) == 0: 51 | wx.MessageBox("プロジェクト名もしくは保存場所が不正です。\n" 52 | "もう一度やり直してください。", "確認", 53 | wx.ICON_EXCLAMATION | wx.OK) 54 | return 55 | if proj_path.exists(): 56 | wx.MessageBox("同じ保存場所にすでに同名のフォルダがあります。\n" 57 | "もう一度やり直してください。", "確認", 58 | wx.ICON_EXCLAMATION | wx.OK) 59 | return 60 | event.Skip() 61 | 62 | def get_path(self): 63 | return Path(self.dir_ctrl.GetPath()) / self.proj_text.GetValue() 64 | -------------------------------------------------------------------------------- /ykl_core/ykl_project.py: -------------------------------------------------------------------------------- 1 | """ 2 | yukkulips 3 | 4 | copyright (c) 2018 - 2020 suitcase 5 | 6 | this software is released under the mit license. 7 | https://github.com/pickledchair/yukkulips/blob/master/license.txt 8 | """ 9 | 10 | from pathlib import Path 11 | import json 12 | 13 | class YKLProject: 14 | def __init__(self, root_path): 15 | self.__root = Path(root_path) 16 | if not self.__root.exists(): 17 | self.__root.mkdir() 18 | self.__resolution = (1920, 1080) 19 | self.__bg_color = (0, 0, 255, 255) 20 | self.__content = YKLProject.__initial_content(self.__resolution, self.__bg_color) 21 | self.__is_saved = True 22 | 23 | def root_path(self): 24 | return self.__root 25 | 26 | @staticmethod 27 | def __initial_content(resolution, bg_color): 28 | content = { 29 | "Project Version": 0.3, 30 | 'SceneBlock List': [], 31 | 'CharaSozai Resource Dict': {}, 32 | 'Resolution': list(resolution), 33 | 'BG Color': list(bg_color), 34 | } 35 | return content 36 | 37 | def open(self): 38 | with (self.root_path() / "project.json").open('r') as f: 39 | content = json.load(f) 40 | self.__content = content 41 | sceneblock_list = content["SceneBlock List"] 42 | sozai_resource_dic = content["CharaSozai Resource Dict"] 43 | sozai_resource_dic = {k: Path(v) for k, v in sozai_resource_dic.items()} 44 | self.__resolution = tuple(content["Resolution"]) 45 | self.__bg_color = tuple(content["BG Color"]) 46 | self.__is_saved = True 47 | return sceneblock_list, sozai_resource_dic 48 | 49 | def is_saved(self): 50 | return self.__is_saved 51 | 52 | def set_unsaved(self): 53 | self.__is_saved = False 54 | 55 | def get_name(self): 56 | return self.__root.name 57 | 58 | def set_sceneblock_list(self, l): 59 | self.__content['SceneBlock List'] = l 60 | 61 | def set_resource_path_dic(self, d): 62 | d = {k: str(v) for k, v in d.items()} 63 | self.__content['CharaSozai Resource Dict'] = d 64 | 65 | @property 66 | def resolution(self): 67 | return self.__resolution 68 | 69 | @resolution.setter 70 | def resolution(self, res): 71 | self.__resolution = res 72 | self.__content['Resolution'] = list(res) 73 | self.__is_saved = False 74 | 75 | @property 76 | def bg_color(self): 77 | return self.__bg_color 78 | 79 | @bg_color.setter 80 | def bg_color(self, bg_color): 81 | self.__bg_color = bg_color 82 | self.__content['BG Color'] = list(bg_color) 83 | self.__is_saved 84 | 85 | def save(self): 86 | proj_file_path = self.__root / 'project.json' 87 | with proj_file_path.open('w') as f: 88 | json.dump(self.__content, f, indent=4, ensure_ascii=False) 89 | self.__is_saved = True 90 | -------------------------------------------------------------------------------- /ykl_ui/project_edit_dialog.py: -------------------------------------------------------------------------------- 1 | """ 2 | YukkuLips 3 | 4 | Copyright (c) 2018 - 2020 SuitCase 5 | 6 | This software is released under the MIT License. 7 | https://github.com/PickledChair/YukkuLips/blob/master/LICENSE.txt 8 | """ 9 | 10 | import wx 11 | 12 | 13 | class YKLProjectEditDialog(wx.Dialog): 14 | def __init__(self, parent, ctx): 15 | super().__init__(parent, title="プロジェクト設定", size=(400, 160)) 16 | self.ctx = ctx 17 | self.resolutions = { 18 | "1080p": (1920, 1080), 19 | "720p": (1280, 720), 20 | } 21 | box = wx.BoxSizer(wx.VERTICAL) 22 | # 解像度設定 23 | resolution_box = wx.BoxSizer(wx.HORIZONTAL) 24 | res_lbl = wx.StaticText(self, wx.ID_ANY, "動画解像度:") 25 | resolution_box.Add(res_lbl, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=10) 26 | self.res_choice = wx.Choice(self, wx.ID_ANY, choices=list(self.resolutions.keys())) 27 | current_idx = self.get_res_idx() 28 | if current_idx: 29 | self.res_choice.SetSelection(current_idx) 30 | resolution_box.Add(self.res_choice, flag=wx.TOP | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 31 | 32 | box.Add(resolution_box, flag=wx.ALIGN_CENTER) 33 | 34 | # 背景色設定 35 | bgcolor_box = wx.BoxSizer(wx.HORIZONTAL) 36 | bg_lbl = wx.StaticText(self, wx.ID_ANY, "背景色:") 37 | bgcolor_box.Add(bg_lbl, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=10) 38 | self.cp_ctrl = wx.ColourPickerCtrl(self, wx.ID_ANY, colour=wx.Colour(*tuple(self.ctx.bg_color))) 39 | bgcolor_box.Add(self.cp_ctrl, flag=wx.TOP | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 40 | 41 | box.Add(bgcolor_box, flag=wx.ALIGN_CENTER) 42 | 43 | # ボタン 44 | btns = wx.BoxSizer(wx.HORIZONTAL) 45 | cancel_btn = wx.Button(self, wx.ID_CANCEL, "キャンセル") 46 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=cancel_btn.GetId()) 47 | btns.Add(cancel_btn, flag=wx.ALL, border=5) 48 | ok_btn = wx.Button(self, wx.ID_OK, "適用して閉じる") 49 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=ok_btn.GetId()) 50 | btns.Add(ok_btn, flag=wx.ALL, border=5) 51 | btns.Layout() 52 | 53 | box.Add(btns, 0, flag=wx.RIGHT | wx.TOP | wx.ALIGN_RIGHT, border=5) 54 | self.SetSizer(box) 55 | 56 | def OnBtnClick(self, event): 57 | if event.GetId() == wx.ID_OK: 58 | self.ctx.resolution = self.get_res_value() 59 | self.ctx.bg_color = self.cp_ctrl.GetColour().Get(includeAlpha=True) 60 | self.EndModal(event.GetId()) 61 | 62 | def get_res_idx(self): 63 | idx = 0 64 | for k, v in self.resolutions.items(): 65 | if v == self.ctx.resolution: 66 | return idx 67 | idx += 1 68 | return None 69 | 70 | def get_res_value(self): 71 | return self.resolutions[ 72 | self.res_choice.GetString(self.res_choice.GetSelection()) 73 | ] 74 | 75 | 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YukkuLips 2 | 3 | ![icon.png](images/icon.png) 4 | 5 | ### これは何? 6 | 7 | キャラ素材を用いた動画作成を支援するmacOS用アプリケーションです。キャラ素材については[こちら](https://dic.nicovideo.jp/a/%E3%82%AD%E3%83%A3%E3%83%A9%E7%B4%A0%E6%9D%90%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88)をご覧ください。また、キャラ素材を入手するには本家の[nicotalk&キャラ素材配布所](http://www.nicotalk.com/charasozai.html)等の配布サイトをお訪ねください。 8 | 9 | このアプリケーションには以下の機能があります: 10 | 11 | - タイトル画面や立ち絵等に利用するために、**キャラ素材を静画出力する機能** 12 | - 音声合成アプリケーションによる音声に合わせて**キャラ素材をリップシンク**させた、**クロマキー合成用の動画を出力する機能** 13 | 14 | 15 | 16 | ### 諸注意 17 | 18 | キャラ素材は本来、Windows用動画制作ソフトである**AviUtl**と、AviUtl用のスクリプトである**キャラ素材スクリプト**による使用を想定して開発されています。YukkuLipsは、そのキャラ素材をmacOS上でiMovie等のアプリケーションによる動画制作に使用できるように、簡易的な機能のみを実装したアプリケーションとなります。キャラ素材の読み込み自体は**キャラ素材スクリプトVer4a**相当を念頭に置いていますが、**キャラ素材スクリプト非対応であること、機能が非常に限定的であることにご注意ください**。 19 | 20 | また、**キャラ素材を用いて作成したコンテンツの発表**に当たっては、オリジナルのキャラ素材スクリプトを開発された**ズーズ様や各キャラ素材作者様が定められた規約**に従う必要があります。詳しくは[利用上の注意](#利用上の注意)をご覧ください。 21 | 22 | 23 | 24 | ### 動作環境 25 | 26 | macOS Catalina で開発・動作確認しています。 27 | 28 | 29 | 30 | ## 導入方法 31 | 32 | ### ダウンロード 33 | 34 | [https://github.com/PickledChair/YukkuLips/releases](https://github.com/PickledChair/YukkuLips/releases)から"YukkuLips-darwin-x64.zip"をダウンロードしてください。 35 | 36 | ### インストール 37 | 38 | zipファイルを解凍したフォルダ内にある"YukkuLips.app"を任意のフォルダに移してください(通常はアプリケーションフォルダ)。 39 | 40 | Macのセキュリティ設定により、ブラウザでダウンロードしたアプリケーションは初回は左クリックで起動できないので、右クリックメニューから「開く」を選択して起動してください。 41 | 42 | 43 | 44 | ## 利用上の注意 45 | 46 | ### 各素材・ツールの規約確認 47 | 48 | キャラ素材を使用したコンテンツを発表するに当たっては、キャラ素材スクリプト利用規約と素材ごとの規約のそれぞれに従ってください。キャラ素材スクリプトを使用した動画制作は**非商用動画に限る**など、重要なルールがあります。詳細についてはズーズ様による[キャラ素材の規約](http://www.nicotalk.com/kiyaku.html)をご参照ください。 49 | 50 | また、動画制作の場合、音声合成ソフトや動画制作ツール等の使用においても、各規約に従ってください。 51 | 52 | ### アプリケーションのフリーズ・異常終了 53 | 54 | アプリケーションがフリーズした場合は、強制終了を行ってください。フリーズ・異常終了その他の不具合報告については[不具合報告・質問・連絡先](#不具合報告・質問・連絡先)をご参照ください。 55 | 56 | 57 | 58 | ## リリースノート 59 | 60 | [https://github.com/PickledChair/YukkuLips/releases](https://github.com/PickledChair/YukkuLips/releases)に順次追加されます。 61 | 62 | 63 | 64 | ## ライセンス 65 | 66 | このプロジェクトは[MIT License](https://github.com/PickledChair/YukkuLips/blob/master/LICENSE.txt)で公開しています。 67 | 68 | 69 | 70 | ## 謝辞 71 | 72 | このアプリケーションはキャラ素材をmacOS上で扱うことを目的としていることから、**キャラ素材の存在なしには成立しないソフトウェアです**。キャラ素材およびキャラ素材スクリプトの開発にご尽力された、次のお二方のお名前を特に挙げさせていただき、感謝申し上げたいと思います。 73 | 74 | - **ズーズ様**(キャラ素材スクリプトの開発、および「yukktalk」,「nicotalk」の開発。[nicotalk&キャラ素材配布所](http://www.nicotalk.com/charasozai.html)を参照) 75 | - **きつね様**(キャラ素材の仕様に関する開発協力、およびキャラ素材の提供。[きつねゆっくりのお部屋](http://www.nicotalk.com/ktykroom.html)を参照) 76 | 77 | キャラ素材の作者様は数多く、ここで名前を全て挙げることはできませんが、キャラ素材に関わる全ての方々に感謝申し上げます。 78 | 79 | また、YukkuLipsは基本的にゆっくり動画(ゆっくり解説、ゆっくり実況)の制作に用いられることを想定しています。したがって、macOS上で**ゆっくりの音声を出力できるアプリケーションの存在も欠かせません**。 80 | 81 | - **でんすけ様**(「[ゆっくろいど](http://www.yukkuroid.com/)」や「[ゆくも!](http://www.yukumo.net/)」の開発) 82 | - **taku-o様** (ゆっくろいどの代替ソフト「[MYukkuriVoice](https://github.com/taku-o/myukkurivoice)」の開発) 83 | 84 | 以上のお二方にも、この場を借りて御礼申し上げます。 85 | 86 | 87 | ## 不具合報告・質問・連絡先 88 | 89 | アプリケーションの不具合報告は[このリポジトリのIssue](https://github.com/PickledChair/YukkuLips/issues)にお願いします。 90 | 91 | また、その他使用方法などのご質問は[ツイッターアカウント](https://twitter.com/pickled_chair)へのDMかメールアドレス ubatamamoon [at] gmail [dot] com へよろしくお願いします。ご要望に関しては、対応が遅れるか未対応とさせていただく可能性があることをご理解ください。 92 | -------------------------------------------------------------------------------- /ykl_ui/import_audio_dialog.py: -------------------------------------------------------------------------------- 1 | """ 2 | YukkuLips 3 | 4 | Copyright (c) 2018 - 2020 SuitCase 5 | 6 | This software is released under the MIT License. 7 | https://github.com/PickledChair/YukkuLips/blob/master/LICENSE.txt 8 | """ 9 | 10 | from pathlib import Path 11 | 12 | import wx 13 | 14 | 15 | class YKLImportAudioDialog(wx.Dialog): 16 | def __init__(self, parent, ctx): 17 | super().__init__(parent, title="音声ファイルパスをインポート", size=(450, 150), style=wx.CAPTION | wx.CLOSE_BOX) 18 | self.ctx = ctx 19 | 20 | choices = ["全てのキャラ素材",] + [sozai.get_name() for sozai in self.ctx.get_sceneblocks()[0].get_sozais()] 21 | 22 | box = wx.BoxSizer(wx.VERTICAL) 23 | chara_box = wx.BoxSizer(wx.HORIZONTAL) 24 | self.chara_choice = wx.Choice(self, wx.ID_ANY, choices=choices) 25 | chara_box.Add(self.chara_choice, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=10) 26 | chara_text = wx.StaticText(self, wx.ID_ANY, "に") 27 | chara_box.Add(chara_text, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=10) 28 | box.Add(chara_box) 29 | 30 | place_box = wx.BoxSizer(wx.HORIZONTAL) 31 | self.place = wx.DirPickerCtrl(self, wx.ID_ANY, message="場所を選択", style=wx.DIRP_SMALL | wx.DIRP_USE_TEXTCTRL) 32 | place_box.Add(self.place, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=10) 33 | place_text = wx.StaticText(self, wx.ID_ANY, "内の音声ファイルをインポート") 34 | place_box.Add(place_text, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=10) 35 | box.Add(place_box) 36 | 37 | btns = wx.BoxSizer(wx.HORIZONTAL) 38 | 39 | cancel_btn = wx.Button(self, wx.ID_CANCEL, "キャンセル") 40 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=cancel_btn.GetId()) 41 | btns.Add(cancel_btn, flag=wx.ALL, border=5) 42 | ok_btn = wx.Button(self, wx.ID_OK, "適用して閉じる") 43 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=ok_btn.GetId()) 44 | btns.Add(ok_btn, flag=wx.ALL, border=5) 45 | # btns.Layout() 46 | 47 | box.Add(btns, 0, flag=wx.RIGHT | wx.TOP | wx.ALIGN_RIGHT, border=5) 48 | box.Layout() 49 | self.SetSizer(box) 50 | 51 | def OnBtnClick(self, event): 52 | if event.GetId() == wx.ID_OK: 53 | place = Path(self.place.GetPath()) 54 | if place.exists(): 55 | paths = [path for path in sorted(place.iterdir()) if path.suffix.lower() in [".mp3", ".wav"]] 56 | idx = 0 57 | finish = False 58 | sel_name = self.chara_choice.GetString(self.chara_choice.GetSelection()) 59 | for block in self.ctx.get_sceneblocks(): 60 | for sozai in block.get_sozais(): 61 | if idx == len(paths): 62 | finish = True 63 | break 64 | if sel_name == "全てのキャラ素材" or sel_name == sozai.get_name(): 65 | if len(sozai.speech_content) > 0: 66 | sozai.anime_audio_path = str(paths[idx]) 67 | sozai.movie_audio_path = str(paths[idx]) 68 | idx += 1 69 | else: 70 | sozai.anime_audio_path = "" 71 | sozai.movie_audio_path = "" 72 | if finish: 73 | break 74 | self.ctx.unsaved_check() 75 | self.EndModal(event.GetId()) 76 | -------------------------------------------------------------------------------- /ykl_core/ykl_script.py: -------------------------------------------------------------------------------- 1 | """ 2 | yukkulips 3 | 4 | copyright (c) 2018 - 2020 suitcase 5 | 6 | this software is released under the mit license. 7 | https://github.com/pickledchair/yukkulips/blob/master/license.txt 8 | """ 9 | 10 | class YKLScript: 11 | def __init__(self, content): 12 | self.__block_sep = "--" 13 | self.__name_sep = ">" 14 | self.__content = content 15 | 16 | @staticmethod 17 | def create_from_text(text): 18 | lines = text.splitlines() 19 | content = [] 20 | current_block = [] 21 | for line in lines: 22 | if line[:2] == "--": 23 | content.append(current_block) 24 | current_block = [] 25 | elif len(line) > 0: 26 | s_speech = line.split(">") 27 | if len(s_speech) == 2: 28 | current_block.append(tuple(s_speech)) 29 | else: 30 | return None 31 | else: 32 | continue 33 | content.append(current_block) 34 | return YKLScript(content) 35 | 36 | @staticmethod 37 | def create_from_context(ctx): 38 | content = [] 39 | for block in ctx.get_sceneblocks(): 40 | content.append( 41 | [(sozai.get_name(), sozai.speech_content) 42 | for sozai in block.get_sozais() 43 | if len(sozai.speech_content) > 0]) 44 | return YKLScript(content) 45 | 46 | def into_text(self): 47 | text = "" 48 | for i, block in enumerate(self.__content): 49 | for speech in block: 50 | text += speech[0] + ">" + speech[1] + "\n" 51 | if i != len(self.__content)-1: 52 | text += "--\n" 53 | return text 54 | 55 | def dist_into_context(self, ctx): 56 | for block, s_block in zip(ctx.get_sceneblocks(), self.__content): 57 | # 全てのキャラ素材のセリフを初期化 58 | for sozai in block.get_sozais(): 59 | sozai.speech_content = "" 60 | idx = 0 61 | for s_speech in s_block: 62 | found = True 63 | while block.get_sozais()[idx].get_name() != s_speech[0]: 64 | idx += 1 65 | if idx >= len(block.get_sozais()): 66 | found = False 67 | break 68 | if not found: 69 | break 70 | else: 71 | block.get_sozais()[idx].speech_content = s_speech[1] 72 | context_len = len(ctx.get_sceneblocks()) 73 | diff = len(self.__content) - context_len 74 | # print(len(self.__content), context_len) 75 | if diff > 0: 76 | ctx.set_current_sceneblock(context_len-1) 77 | ctx.add_sceneblock() 78 | block = ctx.get_current_sceneblock() 79 | for sozai in block.get_sozais(): 80 | sozai.speech_content = "" 81 | for _ in range(diff-1): 82 | ctx.add_sceneblock() 83 | for i in range(diff): 84 | ctx.set_current_sceneblock(context_len+i) 85 | block = ctx.get_current_sceneblock() 86 | idx = 0 87 | for s_speech in self.__content[context_len + i]: 88 | found = True 89 | while block.get_sozais()[idx].get_name() != s_speech[0]: 90 | block.get_sozais()[idx].speech_content = "" 91 | idx += 1 92 | if idx >= len(block.get_sozais()): 93 | found = False 94 | break 95 | if not found: 96 | break 97 | else: 98 | block.get_sozais()[idx].speech_content = s_speech[1] 99 | idx += 1 100 | ctx.unsaved_check() 101 | -------------------------------------------------------------------------------- /ykl_ui/script_edit_dialog.py: -------------------------------------------------------------------------------- 1 | """ 2 | yukkulips 3 | 4 | copyright (c) 2018 - 2020 suitcase 5 | 6 | this software is released under the mit license. 7 | https://github.com/pickledchair/yukkulips/blob/master/license.txt 8 | """ 9 | 10 | import shutil 11 | import subprocess 12 | 13 | import wx 14 | 15 | from ykl_core.ykl_script import YKLScript 16 | 17 | 18 | class YKLScriptEditDialog(wx.Dialog): 19 | def __init__(self, parent, ctx): 20 | super().__init__(parent, title="シナリオ編集", size=(800, 680), style=wx.RESIZE_BORDER | wx.CAPTION | wx.CLOSE_BOX) 21 | self.ctx = ctx 22 | self.temp_dir = self.ctx.get_project_path() / "temp" 23 | 24 | if not self.temp_dir.exists(): 25 | self.temp_dir.mkdir() 26 | box = wx.BoxSizer(wx.VERTICAL) 27 | self.tctrl = wx.TextCtrl(self, wx.ID_ANY, style=wx.TE_MULTILINE | wx.TE_PROCESS_ENTER, size=(780, 500)) 28 | script = YKLScript.create_from_context(self.ctx) 29 | self.tctrl.SetValue(script.into_text()) 30 | box.Add(self.tctrl, 1, flag=wx.ALL | wx.EXPAND, border=10) 31 | 32 | discript_panel = DiscriptionPanel(self, wx.ID_ANY) 33 | box.Add(discript_panel, 1, flag=wx.ALL | wx.EXPAND, border=5) 34 | 35 | btns = wx.BoxSizer(wx.HORIZONTAL) 36 | myv_btn = wx.Button(self, wx.ID_ANY, "MYukkuriVoiceに受け渡す") 37 | self.Bind(wx.EVT_BUTTON, self.OnMYukkuriBtnClick, id=myv_btn.GetId()) 38 | btns.Add(myv_btn, flag=wx.ALL, border=5) 39 | cancel_btn = wx.Button(self, wx.ID_CANCEL, "キャンセル") 40 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=cancel_btn.GetId()) 41 | btns.Add(cancel_btn, flag=wx.ALL, border=5) 42 | ok_btn = wx.Button(self, wx.ID_OK, "適用して閉じる") 43 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=ok_btn.GetId()) 44 | btns.Add(ok_btn, flag=wx.ALL, border=5) 45 | # btns.Layout() 46 | 47 | box.Add(btns, 0, flag=wx.RIGHT | wx.TOP | wx.ALIGN_RIGHT, border=5) 48 | box.Layout() 49 | self.SetSizer(box) 50 | 51 | self.Bind(wx.EVT_CLOSE, self.OnClose, id=self.GetId()) 52 | 53 | def OnClose(self, event): 54 | if self.temp_dir.exists(): 55 | shutil.rmtree(str(self.temp_dir)) 56 | event.Skip() 57 | 58 | def OnMYukkuriBtnClick(self, event): 59 | self.send_to_myukkurivoice() 60 | 61 | def send_to_myukkurivoice(self): 62 | txt_path = self.temp_dir / "scenario.txt" 63 | text = ''.join(self.tctrl.GetValue().split("--\n")) 64 | with txt_path.open('w') as f: 65 | f.write(text) 66 | p = subprocess.Popen(["open", "-a", "MYukkuriVoice", str(txt_path)]) 67 | if p.wait() == 1: 68 | wx.MessageBox("MYukkuriVoice.appはアプリケーションフォルダ内に" 69 | "ある必要があります", 70 | "MYukkuriVoiceを開けませんでした", 71 | wx.ICON_EXCLAMATION | wx.OK) 72 | 73 | def OnBtnClick(self, event): 74 | if event.GetId() == wx.ID_OK: 75 | text = self.tctrl.GetValue() 76 | script = YKLScript.create_from_text(text) 77 | if script: 78 | script.dist_into_context(self.ctx) 79 | else: 80 | wx.MessageBox("スクリプトに正しくない記法が用いられています", 81 | "エラー", wx.ICON_EXCLAMATION | wx.OK) 82 | return 83 | if self.temp_dir.exists(): 84 | shutil.rmtree(str(self.temp_dir)) 85 | self.EndModal(event.GetId()) 86 | 87 | 88 | class DiscriptionPanel(wx.ScrolledWindow): 89 | def __init__(self, parent, idx): 90 | super().__init__(parent, idx, size=(780, 100)) 91 | box = wx.BoxSizer(wx.VERTICAL) 92 | discription = """ 93 | シナリオの記法について: 94 | 95 | 以下のように「(キャラ素材名)>(セリフ)」と書くと、各キャラ素材にセリフを指定できます(">"は全角の三角括弧です)。 96 | (シーンブロックの境目では、"--"とハイフンを2つ続けて入力してください。) 97 | 98 | 例: 99 | れいむ>こんにちは、ゆっくり霊夢です。 100 | -- 101 | まりさ>ゆっくり魔理沙だぜ。 102 | -- 103 | れいむ>ゆっくりしていってね! 104 | まりさ>ゆっくりしていってね! 105 | 106 | (1つのシーンブロックに複数のキャラ素材のセリフを設定する時は、キャラ素材リストにおける順番通りに記載してください。) 107 | """ 108 | syntax = wx.StaticText(self, wx.ID_ANY, discription) 109 | box.Add(syntax) 110 | box.Layout() 111 | self.SetSizer(box) 112 | self.SetVirtualSize(syntax.GetClientSize()) 113 | self.SetScrollRate(20, 20) 114 | -------------------------------------------------------------------------------- /ykl_ui/layout_panel.py: -------------------------------------------------------------------------------- 1 | """ 2 | YukkuLips 3 | 4 | Copyright (c) 2018 - 2020 SuitCase 5 | 6 | This software is released under the MIT License. 7 | https://github.com/PickledChair/YukkuLips/blob/master/LICENSE.txt 8 | """ 9 | 10 | import wx 11 | import wx.lib.agw.buttonpanel as BP 12 | import wx.lib.newevent as NE 13 | 14 | from copy import deepcopy 15 | 16 | from .layout_edit_dialog import YKLLauoutEditDialog 17 | from media_utility.image_tool import get_scene_image, get_rescaled_image 18 | 19 | 20 | YKLLauoutUpdate, EVT_YKL_LAYOUT_UPDATE = NE.NewCommandEvent() 21 | 22 | class YKLLayoutPanel(wx.Panel): 23 | def __init__(self, parent, idx, ctx): 24 | super().__init__(parent, idx) 25 | self.ctx = ctx 26 | vbox = wx.BoxSizer(wx.VERTICAL) 27 | button_panel = BP.ButtonPanel(self, wx.ID_ANY, "レイアウトプレビュー") 28 | bp_art = button_panel.GetBPArt() 29 | bp_art.SetColour(BP.BP_BACKGROUND_COLOUR, wx.Colour(48, 48, 48)) 30 | bp_art.SetColour(BP.BP_TEXT_COLOUR, wx.Colour(220, 220, 220)) 31 | # レイアウト編集ボタン 32 | self.edit_btn = wx.Button(button_panel, wx.ID_ANY, "レイアウト編集") 33 | button_panel.AddControl(self.edit_btn) 34 | self.edit_btn.Enable(False) 35 | self.Bind(wx.EVT_BUTTON, self.OnEditBtnClick, id=self.edit_btn.GetId()) 36 | vbox.Add(button_panel, flag=wx.EXPAND) 37 | # シーンイメージプレビューパネル 38 | if self.ctx: 39 | current_sceneblock = self.ctx.get_current_sceneblock() 40 | if current_sceneblock: 41 | scene_image = current_sceneblock.scene_image 42 | _, h = scene_image.size 43 | scene_image = get_rescaled_image(scene_image, 380/h) 44 | self.edit_btn.Enable() 45 | else: 46 | scene_image = get_scene_image([], [], []) 47 | _, h = scene_image.size 48 | scene_image = get_rescaled_image(scene_image, 380/h) 49 | else: 50 | scene_image = get_scene_image([], [], []) 51 | _, h = scene_image.size 52 | scene_image = get_rescaled_image(scene_image, 380/h) 53 | self.ppanel = PreviewPanel(self, wx.ID_ANY, scene_image) 54 | vbox.Add(self.ppanel, 1, flag=wx.EXPAND) 55 | vbox.Layout() 56 | self.SetSizer(vbox) 57 | 58 | def OnEditBtnClick(self, event): 59 | block = deepcopy(self.ctx.get_current_sceneblock()) 60 | with YKLLauoutEditDialog(self, self.ctx, block) as e_dialog: 61 | ret = e_dialog.ShowModal() 62 | if not (ret == wx.ID_CANCEL or ret == wx.ID_CLOSE): 63 | block.movie_generated = False 64 | self.ctx.set_new_sceneblock(block) 65 | wx.PostEvent(self, YKLLauoutUpdate(self.GetId())) 66 | 67 | def update_layoutpreview(self): 68 | current_sceneblock = self.ctx.get_current_sceneblock() 69 | if current_sceneblock: 70 | scene_image = current_sceneblock.scene_image 71 | _, h = scene_image.size 72 | scene_image = get_rescaled_image(scene_image, 380/h) 73 | self.edit_btn.Enable() 74 | else: 75 | scene_image = get_scene_image([], [], []) 76 | _, h = scene_image.size 77 | scene_image = get_rescaled_image(scene_image, 380/h) 78 | self.edit_btn.Enable(False) 79 | self.ppanel.set_image(scene_image) 80 | 81 | def set_context(self, ctx): 82 | self.ctx = ctx 83 | 84 | 85 | class PreviewPanel(wx.ScrolledWindow): 86 | def __init__(self, parent, idx, image): 87 | super().__init__(parent, idx) 88 | self.max_width = image.size[0] 89 | self.max_height = image.size[1] 90 | hbox = wx.BoxSizer(wx.HORIZONTAL) 91 | bmp = wx.Bitmap.FromBufferRGBA(*image.size, image.tobytes()) 92 | self.sbmp = wx.StaticBitmap(self, wx.ID_ANY, bmp) 93 | hbox.Add(self.sbmp) 94 | hbox.Layout() 95 | self.SetSizer(hbox) 96 | self.SetVirtualSize(self.max_width, self.max_height) 97 | self.SetScrollRate(20, 20) 98 | 99 | def set_image(self, image): 100 | bmp = wx.Bitmap.FromBufferRGBA(*image.size, image.tobytes()) 101 | self.sbmp.SetBitmap(bmp) 102 | size = bmp.GetSize() 103 | self.max_width = size.Width 104 | self.max_height = size.Height 105 | self.SetVirtualSize(self.max_width, self.max_height) 106 | self.Refresh() 107 | -------------------------------------------------------------------------------- /ykl_ui/sozai_panel.py: -------------------------------------------------------------------------------- 1 | """ 2 | yukkulips 3 | 4 | copyright (c) 2018 - 2020 suitcase 5 | 6 | this software is released under the mit license. 7 | https://github.com/pickledchair/yukkulips/blob/master/license.txt 8 | """ 9 | 10 | from copy import deepcopy 11 | 12 | import wx 13 | import wx.lib.agw.buttonpanel as BP 14 | import wx.lib.newevent as NE 15 | 16 | from .sozai_edit_dialog import YKLSozaiEditDialog 17 | from media_utility.image_tool import get_thumbnail 18 | 19 | 20 | YKLSozaiUpdate, EVT_YKL_SOZAI_UPDATE = NE.NewCommandEvent() 21 | 22 | class YKLSozaiPanel(wx.Panel): 23 | def __init__(self, parent, idx, ctx): 24 | super().__init__(parent, idx) 25 | self.ctx = ctx 26 | vbox = wx.BoxSizer(wx.VERTICAL) 27 | button_panel = BP.ButtonPanel(self, wx.ID_ANY, "キャラ素材リスト") 28 | bp_art = button_panel.GetBPArt() 29 | bp_art.SetColour(BP.BP_BACKGROUND_COLOUR, wx.Colour(48, 48, 48)) 30 | bp_art.SetColour(BP.BP_TEXT_COLOUR, wx.Colour(220, 220, 220)) 31 | # キャラ素材編集ボタン 32 | self.edit_btn = wx.Button(button_panel, wx.ID_ANY, "素材編集") 33 | button_panel.AddControl(self.edit_btn) 34 | self.edit_btn.Enable(False) 35 | self.Bind(wx.EVT_BUTTON, self.OnEditBtnClick, id=self.edit_btn.GetId()) 36 | # キャラ素材追加ボタン 37 | add_btn = BP.ButtonInfo(button_panel, wx.ID_ANY, wx.ArtProvider.GetBitmap(wx.ART_PLUS, wx.ART_OTHER, (16, 16))) 38 | button_panel.AddButton(add_btn) 39 | self.Bind(wx.EVT_BUTTON, self.OnAddBtnClick, id=add_btn.GetId()) 40 | # キャラ素材削除ボタン 41 | self.remove_btn = BP.ButtonInfo(button_panel, wx.ID_ANY, wx.ArtProvider.GetBitmap(wx.ART_MINUS, wx.ART_OTHER, (16, 16))) 42 | button_panel.AddButton(self.remove_btn) 43 | self.Bind(wx.EVT_BUTTON, self.OnRemoveBtnClick, id=self.remove_btn.GetId()) 44 | self.remove_btn.SetStatus("Disabled") 45 | vbox.Add(button_panel, flag=wx.EXPAND) 46 | 47 | self.il = wx.ImageList(100, 100) 48 | self.sozai_list = wx.ListCtrl(self, wx.ID_ANY, style=wx.LC_REPORT) 49 | self.sozai_list.SetImageList(self.il, wx.IMAGE_LIST_SMALL) 50 | self.sozai_list.AppendColumn('プレビュー', width=120) 51 | self.sozai_list.AppendColumn('名前', width=150) 52 | self.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnItemSelected, id=self.sozai_list.GetId()) 53 | self.Bind(wx.EVT_LIST_ITEM_DESELECTED, self.OnItemDeselected, id=self.sozai_list.GetId()) 54 | vbox.Add(self.sozai_list, 1, flag=wx.EXPAND | wx.ALL, border=1) 55 | self.SetSizer(vbox) 56 | 57 | button_panel.DoLayout() 58 | vbox.Layout() 59 | 60 | def set_context(self, ctx): 61 | self.ctx = ctx 62 | 63 | def OnItemSelected(self, event): 64 | self.remove_btn.SetStatus("Normal") 65 | self.edit_btn.Enable() 66 | self.Refresh() 67 | 68 | def OnItemDeselected(self, event): 69 | self.remove_btn.SetStatus("Disabled") 70 | self.edit_btn.Enable(False) 71 | self.Refresh() 72 | 73 | def OnAddBtnClick(self, event): 74 | with wx.DirDialog(self, "キャラ素材フォルダを選択") as dir_dialog: 75 | if dir_dialog.ShowModal() == wx.ID_CANCEL: 76 | return 77 | path = dir_dialog.GetPath() 78 | self.ctx.add_sozai(path) 79 | wx.PostEvent(self, YKLSozaiUpdate(self.GetId())) 80 | 81 | def OnEditBtnClick(self, event): 82 | block = self.ctx.get_current_sceneblock() 83 | idx = self.get_selected_idx() 84 | sozai = deepcopy(block.get_sozais()[idx]) 85 | with YKLSozaiEditDialog(self, self.ctx, sozai) as e_dialog: 86 | ret = e_dialog.ShowModal() 87 | if not (ret == wx.ID_CANCEL or ret == wx.ID_CLOSE): 88 | self.ctx.set_new_sozai(idx, sozai) 89 | wx.PostEvent(self, YKLSozaiUpdate(self.GetId())) 90 | 91 | def OnRemoveBtnClick(self, event): 92 | idx = -1 93 | # キャラ素材リストの先頭から削除するため、 94 | # 後続の選択キャラ素材はインデックスが削除数ぶんずれる。 95 | rm_num = 0 96 | while True: 97 | idx = self.sozai_list.GetNextSelected(idx) 98 | if idx == -1: 99 | break 100 | self.ctx.remove_sozai(idx - rm_num) 101 | rm_num += 1 102 | wx.PostEvent(self, YKLSozaiUpdate(self.GetId())) 103 | 104 | def update_sozai_list(self): 105 | self.sozai_list.DeleteAllItems() 106 | self.il.RemoveAll() 107 | block = self.ctx.get_current_sceneblock() 108 | if block: 109 | for sozai in block.get_sozais(): 110 | image = sozai.get_image() 111 | # image = image.resize((100, 100), Image.LANCZOS) 112 | image = get_thumbnail(image, size=100) 113 | bmp = wx.Bitmap.FromBufferRGBA(100, 100, image.tobytes()) 114 | self.il.Add(bmp) 115 | for i, sozai in enumerate(block.get_sozais()): 116 | self.sozai_list.InsertItem(i, '', i) 117 | self.sozai_list.SetItem(i, 1, sozai.get_name()) 118 | self.remove_btn.SetStatus("Disabled") 119 | 120 | self.Refresh() 121 | 122 | def get_selected_idx(self): 123 | for i in range(self.sozai_list.GetItemCount()): 124 | if self.sozai_list.IsSelected(i): 125 | return i 126 | return None 127 | 128 | def any_item_selected(self): 129 | return self.get_selected_idx() is not None 130 | -------------------------------------------------------------------------------- /media_utility/audio_tool.py: -------------------------------------------------------------------------------- 1 | """ 2 | yukkulips 3 | 4 | copyright (c) 2018 - 2020 suitcase 5 | 6 | this software is released under the mit license. 7 | https://github.com/pickledchair/yukkulips/blob/master/license.txt 8 | """ 9 | 10 | import subprocess 11 | import shutil 12 | from pathlib import Path 13 | 14 | import soundfile as sf 15 | import numpy as np 16 | import wx 17 | 18 | 19 | def gen_void_sound(path, fs=44100): 20 | # 基本的に動画に使う音源はサンプリングレートを44100Hzにアップコンバートしている 21 | sf.write(str(path), np.zeros([int(0.5*fs), 1]), fs) 22 | 23 | def get_sound_length(sound_file, add_time=0.): 24 | if sound_file: 25 | data, fs = sf.read(str(sound_file), always_2d=True) 26 | return int(data.shape[0]/1470) + int(add_time*30) 27 | else: 28 | return 0 29 | 30 | def get_sound_map(sound_file, imgs_num, threshold_ratio=1.0, line=lambda x: x**2 / 2, sizing=lambda x: x**2): 31 | data, fs = sf.read(str(sound_file), always_2d=True) 32 | data = [sizing(d[0]) for d in data] 33 | buf = [] 34 | thresholds = [line(1 / imgs_num * (i + 1)) * threshold_ratio for i in range(imgs_num)] 35 | work_num = int(len(data)/1470) 36 | progress_dialog = wx.ProgressDialog( 37 | title="動画出力", 38 | message="動画出力中...", 39 | maximum=work_num, 40 | style=wx.PD_APP_MODAL | wx.PD_SMOOTH | wx.PD_AUTO_HIDE) 41 | progress_dialog.Show() 42 | progress = 0 43 | while len(data) > 1470: 44 | break_flag = False 45 | mean = sum(data[:1470]) / 1470 46 | for i, g in enumerate(thresholds): 47 | if mean <= g: 48 | buf.append(i) 49 | break_flag = True 50 | break 51 | if not break_flag: 52 | buf.append(len(thresholds) - 1) 53 | data = data[1470:] 54 | progress += 1 55 | progress_dialog.Update(progress, "音声に対するリップシンク...({}/{} フレーム)".format(progress, work_num)) 56 | 57 | return buf 58 | 59 | def mix_movie_sounds(file_paths, ffmpeg_path, save_dir): 60 | file_paths = [file_path for file_path in file_paths if file_path] 61 | new_path = str(save_dir / "movie_sound") + ".wav" 62 | if Path(new_path).exists(): 63 | shutil.rmtree(new_path) 64 | input_list = [] 65 | for file_path in file_paths: 66 | input_list.extend(["-i", str(file_path)]) 67 | p = subprocess.Popen([ffmpeg_path,] 68 | + input_list 69 | + ["-filter_complex", f"amix=inputs={len(file_paths)}:duration=longest", new_path]) 70 | p.wait() 71 | return Path(new_path) 72 | 73 | def gen_movie_sound(file_path, ffmpeg_path, save_dir, prefix_time=0.): 74 | new_path = str(save_dir / file_path.stem) + ".wav" 75 | if Path(new_path).exists(): 76 | i = 1 77 | while Path(new_path).exists(): 78 | new_path = str(save_dir / file_path.stem) + str(i) + ".wav" 79 | i += 1 80 | p = subprocess.Popen([ffmpeg_path, "-i", str(file_path), 81 | "-ar", "44100", new_path]) 82 | p.wait() 83 | if prefix_time > 0: 84 | data, fs = sf.read(new_path, always_2d=True) 85 | data = np.vstack( 86 | [np.zeros([int(prefix_time*fs), data.shape[1]]), 87 | data]) 88 | sf.write(new_path, data, fs) 89 | return Path(new_path) 90 | 91 | 92 | def join_sound_files(*sound_files, interval=2.0, audio_cache=None): 93 | samplerate = 0 94 | total_audio_data = None 95 | progress_dialog = wx.ProgressDialog( 96 | title="動画出力", 97 | message="動画出力中...", 98 | maximum=len(sound_files), 99 | style=wx.PD_APP_MODAL | wx.PD_SMOOTH | wx.PD_AUTO_HIDE) 100 | progress_dialog.Show() 101 | progress = 0 102 | for sound_file in sound_files: 103 | data, samplerate = sf.read(sound_file, always_2d=True) 104 | if total_audio_data is None: 105 | total_audio_data = data 106 | progress += 1 107 | progress_dialog.Update(progress, "音声ファイル連結中...({}/{})".format(progress, len(sound_files))) 108 | else: 109 | try: 110 | total_audio_data = np.vstack([total_audio_data, 111 | np.zeros([int(interval*samplerate), total_audio_data.shape[1]])]) 112 | total_audio_data = np.vstack([total_audio_data, data]) 113 | progress += 1 114 | progress_dialog.Update(progress, "音声ファイル連結中...({}/{})".format(progress, len(sound_files))) 115 | except Exception as e: 116 | progress_dialog.Close() 117 | wx.LogError("YukkuLips error: 音声ファイルの結合に失敗しました\n" 118 | "音声ファイル間でチャンネル数が一致していない可能性があります\n" + str(e)) 119 | return 120 | if (total_audio_data is not None) and (audio_cache is not None): 121 | file_name = str(audio_cache / "temp.wav") 122 | sf.write(file_name, total_audio_data, samplerate) 123 | return file_name 124 | 125 | 126 | def convert_mp3_to_wav(file_path, ffmpeg_path, save_dir): 127 | new_path = str(save_dir / file_path.stem) + ".wav" 128 | if Path(new_path).exists(): 129 | i = 1 130 | while Path(new_path).exists(): 131 | new_path = str(save_dir / file_path.stem) + str(i) + ".wav" 132 | i += 1 133 | p = subprocess.Popen([ffmpeg_path, "-i", str(file_path), 134 | "-vn", "-ac", "1", "-ar", "44100", "-acodec", "pcm_s16le", 135 | "-f", "wav", new_path]) 136 | p.wait() 137 | return new_path 138 | 139 | -------------------------------------------------------------------------------- /media_utility/image_tool.py: -------------------------------------------------------------------------------- 1 | """ 2 | yukkulips 3 | 4 | copyright (c) 2018 - 2020 suitcase 5 | 6 | this software is released under the mit license. 7 | https://github.com/pickledchair/yukkulips/blob/master/license.txt 8 | """ 9 | 10 | from PIL import Image, ImageOps, ImageChops 11 | from collections import namedtuple 12 | 13 | 14 | ImageInfo = namedtuple("ImageInfo", ("path", "part", "is_mul", "is_front")) 15 | BgColor = namedtuple("BgColor", ("name", "color_tuple")) 16 | 17 | 18 | def combine_images(*image_infos, base_image=None, base_size=(400, 400), bg_color=(0, 0, 255, 255)): 19 | if base_image is None: 20 | if len(image_infos) > 0: 21 | base_size = Image.open(str(image_infos[0].path)).size 22 | base_image = Image.new("RGBA", base_size, bg_color) 23 | complete_image = base_image 24 | mul_image_infos = [] 25 | front_image_infos = [] 26 | 27 | # 部品画像がベース画像からはみ出ていた時のサイズ修正 28 | def get_modified_image(image, complete_image, bg_color): 29 | if image.size[0] >= complete_image.size[0] and image.size[1] >= complete_image.size[1]: 30 | new_image = Image.new("RGBA", image.size, bg_color) 31 | new_image.paste(complete_image, 32 | (int((image.size[0] - complete_image.size[0]) / 2), 33 | int((image.size[1] - complete_image.size[1]) / 2))) 34 | complete_image = new_image 35 | elif image.size[0] <= complete_image.size[0] and image.size[1] <= complete_image.size[1]: 36 | new_image = Image.new("RGBA", complete_image.size, (0, 0, 0, 0)) 37 | new_image.paste(image, 38 | (int((complete_image.size[0] - image.size[0]) / 2), 39 | int((complete_image.size[1] - image.size[1]) / 2))) 40 | image = new_image 41 | elif image.size[0] >= complete_image.size[0] and image.size[1] <= complete_image.size[1]: 42 | new_image1 = Image.new("RGBA", (image.size[0], complete_image.size[1]), bg_color) 43 | new_image1.paste(complete_image, 44 | (int((image.size[0] - complete_image.size[0]) / 2), 0)) 45 | new_image2 = Image.new("RGBA", (image.size[0], complete_image.size[1]), (0, 0, 0, 0)) 46 | new_image2.paste(image, 47 | (0, int((complete_image.size[1] - image.size[1]) / 2))) 48 | complete_image = new_image1 49 | image = new_image2 50 | elif image.size[0] <= complete_image.size[0] and image.size[1] >= complete_image.size[1]: 51 | new_image1 = Image.new("RGBA", (complete_image.size[0], image.size[1]), bg_color) 52 | new_image1.paste(complete_image, 53 | (0, int((image.size[1] - complete_image.size[1]) / 2))) 54 | new_image2 = Image.new("RGBA", (complete_image.size[0], image.size[1]), (0, 0, 0, 0)) 55 | new_image2.paste(image, 56 | (int((complete_image.size[1] - image.size[1]) / 2), 0)) 57 | complete_image = new_image1 58 | image = new_image2 59 | return image, complete_image 60 | 61 | # 通常の描画順に基づく画像合成 62 | for image_info in image_infos: 63 | image = Image.open(str(image_info.path)) 64 | if (not image_info.is_mul) and (not image_info.is_front): 65 | if image.size != complete_image.size: 66 | image, complete_image = get_modified_image(image, complete_image, bg_color) 67 | complete_image = Image.alpha_composite(complete_image, image) 68 | else: 69 | if image_info.is_mul: 70 | mul_image_infos.append(image_info) 71 | if image_info.is_front: 72 | front_image_infos.append(image_info) 73 | 74 | # 乗算で重ねるとしてはけられていた画像を合成する 75 | for image_info in mul_image_infos: 76 | image = Image.open(str(image_info.path)) 77 | if image.size != complete_image.size: 78 | image, complete_image = get_modified_image(image, complete_image, bg_color) 79 | image = ImageChops.multiply(complete_image, image) 80 | complete_image = Image.alpha_composite(complete_image, image) 81 | 82 | # 基本の描画順ではなく最前面に合成される画像の合成 83 | for image_info in front_image_infos: 84 | image = Image.open(str(image_info.path)) 85 | if image.size != complete_image.size: 86 | image, complete_image = get_modified_image(image, complete_image, bg_color) 87 | complete_image = Image.alpha_composite(complete_image, image) 88 | 89 | return complete_image 90 | 91 | def get_mirror_image(image): 92 | return ImageOps.mirror(image) 93 | 94 | def get_scene_image(images, pos_list, order_list, bg_size=(1920, 1080), bg_color=(0, 0, 225, 225)): 95 | scene_image = Image.new("RGBA", bg_size, bg_color) 96 | # for image, pos in zip(images, pos_list): 97 | for order in order_list: 98 | image = images[order] 99 | pos = pos_list[order] 100 | temp_image = Image.new("RGBA", bg_size, (0, 0, 0, 0)) 101 | if pos[0] > bg_size[0] or pos[1] > bg_size[1]: 102 | continue 103 | if pos[0] < 0: 104 | image = image.crop((-pos[0], 0, image.size[0], image.size[1])) 105 | pos = (0, pos[1]) 106 | if pos[1] < 0: 107 | image = image.crop((0, -pos[1], image.size[0], image.size[1])) 108 | pos = (pos[0], 0) 109 | if pos[0]+image.size[0] > bg_size[0]: 110 | image = image.crop((0, 0, bg_size[0]-pos[0], image.size[1])) 111 | if pos[1]+image.size[1] > bg_size[1]: 112 | image = image.crop((0, 0, image.size[0], bg_size[1]-pos[1])) 113 | temp_image.paste(image, pos) 114 | scene_image = Image.alpha_composite(scene_image, temp_image) 115 | 116 | return scene_image 117 | 118 | def get_rescaled_image(image, scale): 119 | if scale == 1.0: 120 | return image 121 | image = image.resize((int(image.size[0] * scale), int(image.size[1] * scale)), Image.LANCZOS) 122 | return image 123 | 124 | def get_thumbnail(image, size=100): 125 | w, h = image.size 126 | if w >= h: 127 | resize_ratio = size / w 128 | else: 129 | resize_ratio = size / h 130 | image = image.resize((int(w*resize_ratio), int(h*resize_ratio)), Image.LANCZOS) 131 | rw, rh = image.size 132 | bg = Image.new("RGBA", (size, size), (255,255,255,0)) 133 | pos = (max(0, int((size-rw)/2)), max(0, int((size-rh)/2))) 134 | bg.paste(image, pos) 135 | 136 | return bg 137 | 138 | def open_img(path): 139 | return Image.open(str(path)) 140 | 141 | def save_anime_frame(args): 142 | frame, images_dir, _count, digit_num, queue = args[0], args[1], args[2], args[3], args[4] 143 | file_name = "frame" + str(_count).zfill(digit_num) + ".png" 144 | frame.save(str(images_dir / file_name), quality=95) 145 | queue.put(file_name) 146 | -------------------------------------------------------------------------------- /ykl_ui/scenario_panel.py: -------------------------------------------------------------------------------- 1 | """ 2 | YukkuLips 3 | 4 | Copyright (c) 2018 - 2020 SuitCase 5 | 6 | This software is released under the MIT License. 7 | https://github.com/PickledChair/YukkuLips/blob/master/LICENSE.txt 8 | """ 9 | 10 | from copy import deepcopy 11 | from pathlib import Path 12 | import shutil 13 | import os 14 | import subprocess 15 | 16 | import wx 17 | import wx.lib.agw.buttonpanel as BP 18 | import wx.lib.mixins.listctrl as listmix 19 | import wx.lib.newevent as NE 20 | 21 | from .scene_edit_dialog import YKLSceneEditDialog 22 | from .roll_copy_dialog import YKLRollingCopyDialog 23 | 24 | 25 | YKLScenarioUpdate, EVT_YKL_SCENARIO_UPDATE = NE.NewCommandEvent() 26 | 27 | 28 | class YKLScenarioPanel(wx.Panel): 29 | def __init__(self, parent, idx, ctx): 30 | super().__init__(parent, idx) 31 | self.ctx = ctx 32 | vbox = wx.BoxSizer(wx.VERTICAL) 33 | 34 | button_panel = BP.ButtonPanel(self, wx.ID_ANY, "シーンブロックリスト") 35 | bp_art = button_panel.GetBPArt() 36 | bp_art.SetColour(BP.BP_BACKGROUND_COLOUR, wx.Colour(48, 48, 48)) 37 | bp_art.SetColour(BP.BP_TEXT_COLOUR, wx.Colour(220, 220, 220)) 38 | # シーンブロック編集ボタン 39 | self.edit_btn = wx.Button(button_panel, wx.ID_ANY, "シーンブロック編集") 40 | self.Bind(wx.EVT_BUTTON, self.OnEditBtnClick, id=self.edit_btn.GetId()) 41 | button_panel.AddControl(self.edit_btn) 42 | self.edit_btn.Enable(False) 43 | # 動画の連結と保存ボタン 44 | self.save_btn = wx.Button(button_panel, wx.ID_ANY, "動画を連結して保存") 45 | self.Bind(wx.EVT_BUTTON, self.OnSaveBtnClick, id=self.save_btn.GetId()) 46 | button_panel.AddControl(self.save_btn) 47 | self.save_btn.Enable(False) 48 | # ローリングコピーボタン 49 | self.copy_btn = wx.Button(button_panel, wx.ID_ANY, "コピーダイアログ") 50 | self.Bind(wx.EVT_BUTTON, self.OnCopyBtnClick, id=self.copy_btn.GetId()) 51 | button_panel.AddControl(self.copy_btn) 52 | self.copy_btn.Enable(False) 53 | # シーンブロック追加ボタン 54 | add_btn = BP.ButtonInfo(button_panel, wx.ID_ANY, wx.ArtProvider.GetBitmap(wx.ART_PLUS, wx.ART_OTHER, (16, 16))) 55 | button_panel.AddButton(add_btn) 56 | self.Bind(wx.EVT_BUTTON, self.OnAddBtnClick, id=add_btn.GetId()) 57 | # シーンブロック削除ボタン 58 | self.remove_btn = BP.ButtonInfo(button_panel, wx.ID_ANY, wx.ArtProvider.GetBitmap(wx.ART_MINUS, wx.ART_OTHER, (16, 16))) 59 | button_panel.AddButton(self.remove_btn) 60 | self.Bind(wx.EVT_BUTTON, self.OnRemoveBtnClick, id=self.remove_btn.GetId()) 61 | self.remove_btn.SetStatus("Disabled") 62 | vbox.Add(button_panel, flag=wx.EXPAND) 63 | 64 | self.sceneblock_list = ScenarioListCtrl(self, wx.ID_ANY, style=wx.LC_REPORT) 65 | self.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnItemSelected, id=self.sceneblock_list.GetId()) 66 | vbox.Add(self.sceneblock_list, 1, flag=wx.EXPAND | wx.ALL, border=1) 67 | 68 | self.SetSizer(vbox) 69 | 70 | button_panel.DoLayout() 71 | vbox.Layout() 72 | 73 | def OnEditBtnClick(self, event): 74 | block = deepcopy(self.ctx.get_current_sceneblock()) 75 | with YKLSceneEditDialog(self, self.ctx, block) as e_dialog: 76 | ret = e_dialog.ShowModal() 77 | if not (ret == wx.ID_CANCEL or ret == wx.ID_CLOSE): 78 | self.ctx.set_new_sceneblock(block) 79 | wx.PostEvent(self, YKLScenarioUpdate(self.GetId())) 80 | 81 | def OnSaveBtnClick(self, event): 82 | checked_list = [self.sceneblock_list.IsItemChecked(i) for i in range(self.sceneblock_list.GetItemCount())] 83 | blocks = self.ctx.get_sceneblocks() 84 | if any(checked_list): 85 | blocks = [block for block, checked in zip(blocks, checked_list) if checked] 86 | if not all([block.movie_generated for block in blocks]): 87 | if wx.MessageBox("動画未生成のシーンブロックがあります。\n" 88 | "続行する場合は未生成分を自動生成します。" 89 | "続行しますか?", 90 | "確認", wx.ICON_QUESTION | wx.YES_NO, 91 | self) == wx.NO: 92 | return 93 | for block in blocks: 94 | if not block.movie_generated: 95 | block.generate_movie() 96 | with wx.FileDialog( 97 | self, "動画を連結して保存", "", self.ctx.get_project_name(), 98 | wildcard="MP4 files (*.mp4)|*.mp4", style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT 99 | ) as fileDialog: 100 | if fileDialog.ShowModal() == wx.ID_CANCEL: 101 | return 102 | movie_path = fileDialog.GetPath() 103 | self.ctx.concat_movies(movie_path, checked_list) 104 | wx.PostEvent(self, YKLScenarioUpdate(self.GetId())) 105 | p = subprocess.Popen(["open", "-a", "QuickTime Player", str(movie_path)]) 106 | p.wait() 107 | 108 | def OnCopyBtnClick(self, event): 109 | with YKLRollingCopyDialog(self, self.ctx) as e_dialog: 110 | e_dialog.ShowModal() 111 | wx.PostEvent(self, YKLScenarioUpdate(self.GetId())) 112 | 113 | def set_context(self, ctx): 114 | self.ctx = ctx 115 | 116 | def OnAddBtnClick(self, event): 117 | self.ctx.add_sceneblock() 118 | wx.PostEvent(self, YKLScenarioUpdate(self.GetId())) 119 | 120 | def OnRemoveBtnClick(self, event): 121 | checked_list = [self.sceneblock_list.IsItemChecked(i) for i in range(self.sceneblock_list.GetItemCount())] 122 | if not any(checked_list): 123 | self.ctx.remove_sceneblock() 124 | else: 125 | self.ctx.remove_sceneblocks_list(checked_list) 126 | wx.PostEvent(self, YKLScenarioUpdate(self.GetId())) 127 | 128 | def OnItemSelected(self, event): 129 | self.ctx.set_current_sceneblock(event.Index) 130 | wx.PostEvent(self, YKLScenarioUpdate(self.GetId())) 131 | 132 | def update_sceneblock_list(self): 133 | self.sceneblock_list.ClearAll() 134 | if self.ctx.get_current_sceneblock(): 135 | first_sozais = self.ctx.get_sceneblocks()[0].get_sozais() 136 | sozais_num = len(first_sozais) 137 | for i in range(sozais_num+2): 138 | if i == 0: 139 | self.sceneblock_list.AppendColumn('', format=wx.LIST_FORMAT_RIGHT, width=80) 140 | elif i == sozais_num+1: 141 | self.sceneblock_list.AppendColumn('動画生成', format=wx.LIST_FORMAT_CENTER, width=80) 142 | if 0 < sozais_num < 4: 143 | w = int(1000 / sozais_num) 144 | self.sceneblock_list.AppendColumn('', width=w) 145 | else: 146 | name = first_sozais[i-1].get_name() 147 | self.sceneblock_list.AppendColumn(name, width=280) 148 | for i, block in enumerate(self.ctx.get_sceneblocks()): 149 | self.sceneblock_list.InsertItem(i, str(i)) 150 | for j, sozai in enumerate(block.get_sozais()): 151 | self.sceneblock_list.SetItem(i, j+1, sozai.speech_content) 152 | status = "済" if block.movie_generated else "未" 153 | self.sceneblock_list.SetItem(i, sozais_num+1, status) 154 | idx = self.ctx.get_sceneblocks().index(self.ctx.get_current_sceneblock()) 155 | self.sceneblock_list.SetItemBackgroundColour(idx, wx.Colour(90, 200, 250)) 156 | self.sceneblock_list.Focus(idx) 157 | if sozais_num == 0: 158 | self.remove_btn.SetStatus("Disabled") 159 | self.edit_btn.Enable(False) 160 | self.save_btn.Enable(False) 161 | self.copy_btn.Enable(False) 162 | else: 163 | self.remove_btn.SetStatus("Normal") 164 | self.edit_btn.Enable() 165 | if len(self.ctx.get_sceneblocks()) > 1: 166 | self.save_btn.Enable() 167 | else: 168 | self.save_btn.Enable(False) 169 | self.copy_btn.Enable() 170 | else: 171 | self.remove_btn.SetStatus("Disabled") 172 | self.copy_btn.Enable(False) 173 | self.edit_btn.Enable(False) 174 | self.save_btn.Enable(False) 175 | 176 | self.Refresh() 177 | 178 | class ScenarioListCtrl(wx.ListView): 179 | def __init__(self, parent, idx, pos=wx.DefaultPosition, 180 | size=wx.DefaultSize, style=0): 181 | wx.ListCtrl.__init__(self, parent, idx, pos, size, style) 182 | # listmix.CheckListCtrlMixin.__init__(self) 183 | # listmix.ListCtrlAutoWidthMixin.__init__(self) 184 | self.EnableCheckBoxes(True) 185 | -------------------------------------------------------------------------------- /ykl_ui/roll_copy_dialog.py: -------------------------------------------------------------------------------- 1 | """ 2 | YukkuLips 3 | 4 | Copyright (c) 2018 - 2020 SuitCase 5 | 6 | This software is released under the MIT License. 7 | https://github.com/PickledChair/YukkuLips/blob/master/LICENSE.txt 8 | """ 9 | 10 | import shutil 11 | import subprocess 12 | from pathlib import Path 13 | 14 | import wx 15 | 16 | from media_utility.image_tool import get_thumbnail 17 | 18 | 19 | class YKLRollingCopyDialog(wx.Dialog): 20 | def __init__(self, parent, ctx): 21 | super().__init__(parent, title="コピーダイアログ", size=(580, 390)) 22 | self.ctx = ctx 23 | idx = self.ctx.get_sceneblocks().index(self.ctx.get_current_sceneblock()) 24 | self.block_idx = idx 25 | self.MAX_BLOCK_IDX = len(self.ctx.get_sceneblocks()) - 1 26 | self.chara_idx = 0 27 | self.MAX_CHARA_IDX = len(self.ctx.get_sceneblocks()[0].get_sozais()) - 1 28 | 29 | self.temp_dir = self.ctx.get_project_path() / "temp" 30 | if not self.temp_dir.exists(): 31 | self.temp_dir.mkdir() 32 | 33 | self.Bind(wx.EVT_CLOSE, self.OnClose, id=self.GetId()) 34 | 35 | box = wx.BoxSizer(wx.VERTICAL) 36 | self.content_panel = CopyContentPanel(self, wx.ID_ANY, self.get_current_chara(), self.block_idx, self.temp_dir, size=(580, 300)) 37 | box.Add(self.content_panel) 38 | 39 | self.next_check = wx.CheckBox(self, wx.ID_ANY, "コピー操作時に次のセリフに移る") 40 | self.next_check.SetValue(self.ctx.app_setting.setting_dict["CopyDialog Next Check"]) 41 | self.Bind(wx.EVT_CHECKBOX, self.OnCheck, id=self.next_check.GetId()) 42 | box.Add(self.next_check, flag=wx.BOTTOM | wx.ALIGN_CENTER_HORIZONTAL, border=10) 43 | 44 | btns = wx.BoxSizer(wx.HORIZONTAL) 45 | myv_btn = wx.Button(self, wx.ID_ANY, "MYukkuriVoiceに受け渡す") 46 | self.Bind(wx.EVT_BUTTON, self.OnMyvBtnClick, id=myv_btn.GetId()) 47 | btns.Add(myv_btn, flag=wx.ALL, border=5) 48 | copy_btn = wx.Button(self, wx.ID_ANY, "コピー") 49 | self.Bind(wx.EVT_BUTTON, self.OnCopyBtnClick, id=copy_btn.GetId()) 50 | btns.Add(copy_btn, flag=wx.ALL, border=5) 51 | prev_btn = wx.Button(self, wx.ID_ANY, "戻る") 52 | self.Bind(wx.EVT_BUTTON, self.OnPrevBtnClick, id=prev_btn.GetId()) 53 | btns.Add(prev_btn, flag=wx.ALL, border=5) 54 | next_btn = wx.Button(self, wx.ID_ANY, "進む") 55 | self.Bind(wx.EVT_BUTTON, self.OnNextBtnClick, id=next_btn.GetId()) 56 | btns.Add(next_btn, flag=wx.ALL, border=5) 57 | btns.Layout() 58 | 59 | box.Add(btns, 0, flag=wx.RIGHT | wx.ALIGN_RIGHT, border=5) 60 | 61 | box.Layout() 62 | 63 | self.SetSizer(box) 64 | 65 | if not self.contents_check(): 66 | wx.MessageBox("コピーダイアログを終了してください", 67 | "全てのキャラ素材のセリフが空欄です", 68 | wx.ICON_EXCLAMATION | wx.OK) 69 | myv_btn.Enable(False) 70 | copy_btn.Enable(False) 71 | prev_btn.Enable(False) 72 | next_btn.Enable(False) 73 | return 74 | 75 | if self.get_current_chara().speech_content == "": 76 | self.content_increment() 77 | else: 78 | self.save_temp_txt() 79 | 80 | def OnCheck(self, event): 81 | self.ctx.app_setting.setting_dict["CopyDialog Next Check"] = self.next_check.GetValue() 82 | self.ctx.app_setting.save() 83 | 84 | def OnPrevBtnClick(self, event): 85 | self.content_decrement() 86 | 87 | def OnNextBtnClick(self, event): 88 | self.content_increment() 89 | 90 | def OnCopyBtnClick(self, event): 91 | self.content_panel.copy_speech_content() 92 | # next_checkフラグが立っている時はcontent_incrementを呼ぶ 93 | if self.next_check.IsChecked(): 94 | self.content_increment() 95 | 96 | def OnMyvBtnClick(self, event): 97 | self.send_to_myukkurivoice() 98 | if self.next_check.IsChecked(): 99 | self.content_increment() 100 | 101 | def send_to_myukkurivoice(self): 102 | txt_path = self.temp_dir / "serifu.txt" 103 | p = subprocess.Popen(["open", "-a", "MYukkuriVoice", str(txt_path)]) 104 | if p.wait() == 1: 105 | wx.MessageBox("MYukkuriVoice.appはアプリケーションフォルダ内に" 106 | "ある必要があります", 107 | "MYukkuriVoiceを開けませんでした", 108 | wx.ICON_EXCLAMATION | wx.OK) 109 | 110 | def contents_check(self): 111 | for block in self.ctx.get_sceneblocks(): 112 | for chara in block.get_sozais(): 113 | if chara.speech_content != "": 114 | return True 115 | return False 116 | 117 | def OnClose(self, event): 118 | self.ctx.unsaved_check() 119 | if self.temp_dir.exists(): 120 | shutil.rmtree(str(self.temp_dir)) 121 | event.Skip() 122 | 123 | def get_current_chara(self): 124 | return self.ctx.get_sceneblocks()[self.block_idx].get_sozais()[self.chara_idx] 125 | 126 | def save_temp_txt(self): 127 | txt_path = self.temp_dir / "serifu.txt" 128 | with txt_path.open('w') as f: 129 | f.write(self.get_current_chara().speech_content) 130 | 131 | def content_increment(self): 132 | def increment(): 133 | if self.chara_idx == self.MAX_CHARA_IDX: 134 | self.chara_idx = 0 135 | if self.block_idx == self.MAX_BLOCK_IDX: 136 | self.block_idx = 0 137 | else: 138 | self.block_idx += 1 139 | else: 140 | self.chara_idx += 1 141 | increment() 142 | while self.get_current_chara().speech_content == "": 143 | increment() 144 | # 現在のキャラ素材のセリフを表示領域にセットする 145 | self.content_panel.set_charasozai(self.get_current_chara(), self.block_idx) 146 | # また、一時ファイルにセリフを保存する 147 | self.save_temp_txt() 148 | 149 | def content_decrement(self): 150 | def decrement(): 151 | if self.chara_idx == 0: 152 | self.chara_idx = self.MAX_CHARA_IDX 153 | if self.block_idx == 0: 154 | self.block_idx = self.MAX_BLOCK_IDX 155 | else: 156 | self.block_idx -= 1 157 | else: 158 | self.chara_idx -= 1 159 | decrement() 160 | while self.get_current_chara().speech_content == "": 161 | decrement() 162 | # 現在のキャラ素材のセリフを表示領域にセットする 163 | self.content_panel.set_charasozai(self.get_current_chara(), self.block_idx) 164 | # また、一時ファイルにセリフを保存する 165 | self.save_temp_txt() 166 | 167 | 168 | class CopyContentPanel(wx.Panel): 169 | def __init__(self, parent, idx, chara, block_num, temp_dir, size): 170 | super().__init__(parent, idx, size=size) 171 | self.chara = chara 172 | self.temp_dir = temp_dir 173 | box = wx.BoxSizer(wx.VERTICAL) 174 | image = chara.get_image() 175 | image = get_thumbnail(image, size=200) 176 | bmp = wx.Bitmap.FromBufferRGBA(*image.size, image.tobytes()) 177 | hbox = wx.BoxSizer(wx.HORIZONTAL) 178 | self.sbmp = wx.StaticBitmap(self, wx.ID_ANY, bmp) 179 | hbox.Add(self.sbmp, flag=wx.ALL, border=10) 180 | 181 | txt_content_box = wx.BoxSizer(wx.VERTICAL) 182 | name_box = wx.BoxSizer(wx.HORIZONTAL) 183 | self.name_tctrl = wx.TextCtrl(self, wx.ID_ANY, chara.get_name(), size=(100, -1)) 184 | self.name_tctrl.SetEditable(False) 185 | name_box.Add(self.name_tctrl, flag=wx.ALIGN_CENTER_VERTICAL) 186 | name_stlbl = wx.StaticText(self, wx.ID_ANY, "@シーンブロック") 187 | name_box.Add(name_stlbl, flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL, border=10) 188 | self.blocknum_tctrl = wx.TextCtrl(self, wx.ID_ANY, str(block_num), size=(60, -1)) 189 | self.blocknum_tctrl.SetEditable(False) 190 | name_box.Add(self.blocknum_tctrl, flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL, border=10) 191 | txt_content_box.Add(name_box, flag=wx.ALL, border=10) 192 | self.serifu_tctrl = wx.TextCtrl(self, wx.ID_ANY, chara.speech_content, size=(300, 180)) 193 | txt_content_box.Add(self.serifu_tctrl, flag=wx.LEFT | wx.RIGHT | wx.EXPAND, border=10) 194 | hbox.Add(txt_content_box, flag=wx.TOP | wx.BOTTOM | wx.EXPAND, border=10) 195 | box.Add(hbox, flag=wx.EXPAND) 196 | 197 | path_box = wx.BoxSizer(wx.HORIZONTAL) 198 | path_lbl = wx.StaticText(self, wx.ID_ANY, "音声ファイルパス:") 199 | path_box.Add(path_lbl, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=10) 200 | self.path_tctrl = wx.TextCtrl(self, wx.ID_ANY, chara.anime_audio_path, size=(350, -1)) 201 | path_droptarget = PathDropTarget(self.path_tctrl) 202 | self.path_tctrl.SetDropTarget(path_droptarget) 203 | self.Bind(wx.EVT_TEXT, self.OnPathChange, id=self.path_tctrl.GetId()) 204 | path_box.Add(self.path_tctrl, flag=wx.TOP | wx.BOTTOM, border=10) 205 | box.Add(path_box, flag=wx.ALIGN_CENTER_HORIZONTAL) 206 | box.Layout() 207 | self.SetSizer(box) 208 | 209 | def OnPathChange(self, event): 210 | path = self.path_tctrl.GetValue() 211 | if Path(path).exists(): 212 | if path != self.chara.anime_audio_path: 213 | self.chara.anime_audio_path = path 214 | self.chara.movie_audio_path = path 215 | 216 | def set_charasozai(self, chara, block_num): 217 | self.chara = chara 218 | image = chara.get_image() 219 | image = get_thumbnail(image, size=200) 220 | bmp = wx.Bitmap.FromBufferRGBA(*image.size, image.tobytes()) 221 | self.sbmp.SetBitmap(bmp) 222 | self.name_tctrl.SetValue(chara.get_name()) 223 | self.blocknum_tctrl.SetValue(str(block_num)) 224 | self.serifu_tctrl.SetValue(chara.speech_content) 225 | self.path_tctrl.SetValue(chara.anime_audio_path) 226 | self.Refresh() 227 | 228 | # def get_speech_content(self): 229 | # return self.serifu_tctrl.GetValue() 230 | 231 | def copy_speech_content(self): 232 | # フォーカスしないとSelectAllメソッドが機能しない 233 | self.serifu_tctrl.SetFocus() 234 | # 選択しないとCopyメソッドが機能しない 235 | self.serifu_tctrl.SelectAll() 236 | self.serifu_tctrl.Copy() 237 | 238 | 239 | class PathDropTarget(wx.FileDropTarget): 240 | def __init__(self, window): 241 | super().__init__() 242 | self.window = window 243 | 244 | def OnDropFiles(self, x, y, filenames): 245 | if filenames[0][-4:].lower() in [".mp3", ".wav"]: 246 | self.window.SetValue(filenames[0]) 247 | return True 248 | 249 | -------------------------------------------------------------------------------- /ykl_core/ykl_context.py: -------------------------------------------------------------------------------- 1 | """ 2 | yukkulips 3 | 4 | copyright (c) 2018 - 2020 suitcase 5 | 6 | this software is released under the mit license. 7 | https://github.com/pickledchair/yukkulips/blob/master/license.txt 8 | """ 9 | 10 | from pathlib import Path 11 | from uuid import uuid4 12 | import shutil 13 | import os 14 | from copy import deepcopy 15 | 16 | import wx 17 | 18 | from .ykl_appsetting import YKLAppSetting 19 | from .scene_block import SceneBlock 20 | from .ykl_project import YKLProject 21 | from media_utility.video_tool import make_mv_list_txt, concat_movies, FFMPEG_PATH 22 | 23 | 24 | class YKLContext: 25 | def __init__(self, project_root): 26 | self.app_setting = YKLAppSetting() 27 | self.app_setting.load() 28 | self.__project = YKLProject(project_root) 29 | self.__scene_block_dic = {} 30 | self.__current_block = None 31 | self.ffmpeg_path = FFMPEG_PATH 32 | 33 | # scene_blockフォルダの作成 34 | scene_block_folder = self.__project.root_path() / "scene_block" 35 | if not scene_block_folder.exists(): 36 | scene_block_folder.mkdir() 37 | 38 | def add_sozai(self, path): 39 | path = Path(path) 40 | new_uuid = str(uuid4()) 41 | if self.__scene_block_dic: 42 | for scene_block in self.__scene_block_dic.values(): 43 | scene_block.add_sozai(new_uuid, path) 44 | else: 45 | self.add_sceneblock(sozai_dic={new_uuid: path}) 46 | self.__set_project_unsaved() 47 | 48 | def set_new_sozai(self, idx, sozai): 49 | current_block = self.__scene_block_dic[self.__current_block] 50 | current_block.set_new_sozai(idx, sozai) 51 | name = sozai.get_name() 52 | for block in self.__scene_block_dic.values(): 53 | sozais = block.get_sozais() 54 | same_sozai = sozais[idx] 55 | if same_sozai.get_name() != name: 56 | same_sozai.set_name(name) 57 | # 結局同じキャラ素材オブジェクトを指定し直すだけなので、コピーを取らなくて良い 58 | block.set_new_sozai(idx, same_sozai) 59 | self.__set_project_unsaved() 60 | 61 | def remove_sozai(self, idx): 62 | for scene_block in self.__scene_block_dic.values(): 63 | scene_block.remove_sozai(idx) 64 | self.__set_project_unsaved() 65 | 66 | def add_sceneblock(self, sozai_dic={}): 67 | new_uuid = str(uuid4()) 68 | if self.__scene_block_dic: 69 | new_dic = {} 70 | block_uuids = list(self.__scene_block_dic.keys()) 71 | current_idx = block_uuids.index(self.__current_block) 72 | new_sceneblock = self.__scene_block_dic[self.__current_block].get_copy(new_uuid) 73 | block_uuids.insert(current_idx+1, new_uuid) 74 | for b_uuid in block_uuids: 75 | if b_uuid == new_uuid: 76 | new_dic[b_uuid] = new_sceneblock 77 | else: 78 | new_dic[b_uuid] = self.__scene_block_dic[b_uuid] 79 | self.__scene_block_dic = new_dic 80 | else: 81 | self.__scene_block_dic[new_uuid] = SceneBlock(new_uuid, sozai_dic, self.__project) 82 | self.__current_block = new_uuid 83 | self.__set_project_unsaved() 84 | 85 | def set_new_sceneblock(self, block): 86 | self.__scene_block_dic[block.get_uuid()] = block 87 | self.__set_project_unsaved() 88 | 89 | def remove_sceneblock(self): 90 | idx = list(self.__scene_block_dic.keys()).index(self.__current_block) 91 | self.__scene_block_dic.pop(self.__current_block) 92 | if len(self.__scene_block_dic) == 0: 93 | self.__current_block = None 94 | else: 95 | self.__current_block = list(self.__scene_block_dic.keys())[min(idx, len(self.__scene_block_dic)-1)] 96 | self.__set_project_unsaved() 97 | 98 | def remove_sceneblocks_list(self, bool_list): 99 | self.__scene_block_dic = { 100 | item[0]: item[1] for checked, item in zip(bool_list, self.__scene_block_dic.items()) if not checked} 101 | if len(self.__scene_block_dic) == 0: 102 | self.__current_block = None 103 | elif self.__scene_block_dic.get(self.__current_block) is None: 104 | self.set_current_sceneblock(0) 105 | self.__set_project_unsaved() 106 | 107 | def get_sceneblocks(self): 108 | return list(self.__scene_block_dic.values()) 109 | 110 | def unsaved_check(self): 111 | for block in self.get_sceneblocks(): 112 | block.sozais_unsaved_check() 113 | if not all([block.is_saved() for block in self.get_sceneblocks()]): 114 | self.__set_project_unsaved() 115 | 116 | def get_current_sceneblock(self): 117 | return self.__scene_block_dic.get(self.__current_block) 118 | 119 | def get_project_path(self): 120 | return self.__project.root_path() 121 | 122 | def project_saved(self): 123 | return self.__project.is_saved() 124 | 125 | def __set_project_unsaved(self): 126 | self.__project.set_unsaved() 127 | 128 | def get_project_name(self): 129 | return self.__project.get_name() 130 | 131 | @property 132 | def resolution(self): 133 | return self.__project.resolution 134 | 135 | @resolution.setter 136 | def resolution(self, res): 137 | # projectのresolutionセッターで内部的にunsavedにしてある 138 | self.__project.resolution = res 139 | progress_dialog = wx.ProgressDialog( 140 | title="プロジェクト変更の反映", 141 | message="プロジェクト変更中...", 142 | maximum=len(self.__scene_block_dic), 143 | style=wx.PD_APP_MODAL | wx.PD_SMOOTH | wx.PD_AUTO_HIDE) 144 | progress_dialog.Show() 145 | progress = 0 146 | for block in self.__scene_block_dic.values(): 147 | block.update_scene_image() 148 | progress += 1 149 | progress_dialog.Update( 150 | progress, 151 | f"解像度の反映 {progress}/{len(self.__scene_block_dic)}") 152 | 153 | @property 154 | def bg_color(self): 155 | return self.__project.bg_color 156 | 157 | @bg_color.setter 158 | def bg_color(self, new_color): 159 | # projectのbg_colorセッターで内部的にunsavedにしてある 160 | self.__project.bg_color = new_color 161 | progress_dialog = wx.ProgressDialog( 162 | title="プロジェクト変更の反映", 163 | message="プロジェクト変更中...", 164 | maximum=len(self.__scene_block_dic), 165 | style=wx.PD_APP_MODAL | wx.PD_SMOOTH | wx.PD_AUTO_HIDE) 166 | progress_dialog.Show() 167 | progress = 0 168 | for block in self.__scene_block_dic.values(): 169 | block.update_scene_image() 170 | progress += 1 171 | progress_dialog.Update( 172 | progress, 173 | f"背景色の反映 {progress}/{len(self.__scene_block_dic)}") 174 | 175 | def open_project(self): 176 | sceneblock_list, sozai_resource_dic = self.__project.open() 177 | # parts_path_dicsを得るために、ダミーのシーンブロックを作成 178 | s1 = SceneBlock(sceneblock_list[0], sozai_resource_dic, self.__project) 179 | parts_path_dics = [cs.get_parts_path_dic() for cs in s1.get_sozais()] 180 | progress_dialog = wx.ProgressDialog( 181 | title="プロジェクト読込", 182 | message="プロジェクト読込中...", 183 | maximum=len(sceneblock_list), 184 | style=wx.PD_APP_MODAL | wx.PD_SMOOTH | wx.PD_AUTO_HIDE) 185 | progress_dialog.Show() 186 | progress = 0 187 | self.__scene_block_dic = dict() 188 | for s in sceneblock_list: 189 | self.__scene_block_dic[s] = SceneBlock.open( 190 | s, deepcopy(sozai_resource_dic), self.__project, deepcopy(parts_path_dics)) 191 | progress += 1 192 | progress_dialog.Update( 193 | progress, 194 | f"シーンブロック読込 {progress}/{len(sceneblock_list)}") 195 | self.set_current_sceneblock(0) 196 | 197 | def save_project(self): 198 | if self.__project.is_saved(): 199 | return 200 | else: 201 | scene_block_folder = self.__project.root_path() / "scene_block" 202 | # シーンブロックの増減を反映してフォルダを削除 203 | items = [item.name for item in scene_block_folder.iterdir() if item.is_dir()] 204 | # print(items) 205 | delete_blocks = [item for item in items if item not in list(self.__scene_block_dic.keys())] 206 | # print(delete_blocks) 207 | for delete_block in delete_blocks: 208 | shutil.rmtree(scene_block_folder / delete_block) 209 | # プロジェクトに一つもシーンブロックがない場合、sozai_resource_dicの取得でリストの範囲外エラーが起きるので、 210 | # contextの保存を行わないようにする 211 | if len(self.__scene_block_dic) == 0: 212 | return 213 | for block in self.__scene_block_dic.values(): 214 | block.save() 215 | self.__project.set_sceneblock_list(list(self.__scene_block_dic.keys())) 216 | self.__project.set_resource_path_dic(list(self.__scene_block_dic.values())[0].get_sozai_resource_dic()) 217 | self.__project.save() 218 | 219 | def concat_movies(self, movie_path, check_list): 220 | # 動画未生成のシーンブロックがある場合、連結を行わない 221 | if not any(check_list): 222 | if not all([block.movie_generated for block in self.get_sceneblocks()]): 223 | return 224 | else: 225 | checked_blocks = [block for block, checked in zip(self.get_sceneblocks(), check_list) if checked] 226 | if not all([block.movie_generated for block in checked_blocks]): 227 | return 228 | pro_path = self.get_project_path() 229 | temp_path = pro_path / "temp" 230 | if not temp_path.exists(): 231 | temp_path.mkdir() 232 | while not temp_path.exists(): 233 | pass 234 | if Path(movie_path).exists(): 235 | os.remove(movie_path) 236 | while Path(movie_path).exists(): 237 | pass 238 | txt_path = temp_path / "mv_list.txt" 239 | if not any(check_list): 240 | mv_path_list = [block.movie_path for block in self.get_sceneblocks()] 241 | else: 242 | mv_path_list = [block.movie_path for block in checked_blocks] 243 | make_mv_list_txt(txt_path, mv_path_list) 244 | while not txt_path.exists(): 245 | pass 246 | concat_movies(str(FFMPEG_PATH), txt_path, movie_path) 247 | while not Path(movie_path).exists(): 248 | pass 249 | shutil.rmtree(temp_path) 250 | self.__set_project_unsaved() 251 | self.save_project() 252 | 253 | def set_current_sceneblock(self, idx): 254 | self.__current_block = list(self.__scene_block_dic.keys())[idx] 255 | -------------------------------------------------------------------------------- /YukkuLips.py: -------------------------------------------------------------------------------- 1 | """ 2 | YukkuLips 3 | 4 | Copyright (c) 2018 - 2020 SuitCase 5 | 6 | This software is released under the MIT License. 7 | https://github.com/PickledChair/YukkuLips/blob/master/LICENSE.txt 8 | """ 9 | 10 | from copy import deepcopy 11 | from pathlib import Path 12 | import sys 13 | import webbrowser 14 | 15 | import wx 16 | import wx.adv as ADV 17 | 18 | from ykl_core.ykl_context import YKLContext 19 | 20 | from ykl_ui.layout_panel import YKLLayoutPanel, EVT_YKL_LAYOUT_UPDATE 21 | from ykl_ui.sozai_panel import YKLSozaiPanel, EVT_YKL_SOZAI_UPDATE 22 | from ykl_ui.scenario_panel import YKLScenarioPanel, EVT_YKL_SCENARIO_UPDATE 23 | from ykl_ui.welcome_dialog import YKLWelcomeDialog 24 | from ykl_ui.create_pro_dialog import YKLCreateProjectDialog 25 | from ykl_ui.project_edit_dialog import YKLProjectEditDialog 26 | from ykl_ui.script_edit_dialog import YKLScriptEditDialog 27 | from ykl_ui.import_audio_dialog import YKLImportAudioDialog 28 | 29 | 30 | VERSION = "0.2.5" 31 | 32 | YKL_REPOSITORY_URL = "https://github.com/PickledChair/YukkuLips" 33 | YKL_LIBRARIES_URL = "https://github.com/PickledChair/YukkuLips/blob/master/LIBRARIES.txt" 34 | 35 | 36 | class YKLAppWindow(wx.Frame): 37 | def __init__(self, parent, idx, title, size=(1000, 680)): 38 | super().__init__(parent, idx, title, size=size) 39 | self.ctx = None 40 | self.ctx_created = False 41 | 42 | self.create_widgets() 43 | self.create_menu() 44 | 45 | self.Centre() 46 | self.Show() 47 | 48 | self.img_dir = Path.cwd() / 'images' 49 | if getattr(sys, 'frozen', False): 50 | # frozen は PyInstaller でこのスクリプトが固められた時に sys に追加される属性 51 | # frozen が見つからない時は素の Python で実行している時なので False を返す 52 | bundle_dir = sys._MEIPASS 53 | self.img_dir = Path(bundle_dir) / "images" 54 | 55 | previous = wx.NewIdRef() 56 | # history = wx.NewIdRef() 57 | new_pro = wx.NewIdRef() 58 | 59 | with YKLWelcomeDialog(self, [previous, new_pro], self.img_dir) as w_dialog: 60 | ret_id = w_dialog.ShowModal() 61 | if ret_id == previous: 62 | with wx.DirDialog(self, 'プロジェクトフォルダを選択') as dir_dialog: 63 | if dir_dialog.ShowModal() == wx.ID_CANCEL: 64 | self.Close() 65 | return 66 | path = dir_dialog.GetPath() 67 | if not (Path(path) / "project.json").exists(): 68 | wx.MessageBox("指定フォルダ内にproject.jsonがありません\n" 69 | "YukkuLipsを終了します", "確認", 70 | wx.ICON_EXCLAMATION | wx.OK) 71 | self.Close() 72 | # elif ret_id == history: 73 | # print('history') 74 | elif ret_id == new_pro: 75 | with YKLCreateProjectDialog(self) as create_dialog: 76 | if create_dialog.ShowModal() == wx.ID_CANCEL: 77 | self.Close() 78 | return 79 | path = create_dialog.get_path() 80 | else: 81 | self.Close() 82 | return 83 | 84 | self.ctx = YKLContext(path) 85 | self.ctx_created = True 86 | self.scenario_panel.set_context(self.ctx) 87 | self.sozai_panel.set_context(self.ctx) 88 | self.layout_panel.set_context(self.ctx) 89 | self.update_title() 90 | 91 | if (Path(path) / "project.json").exists(): 92 | self.ctx.open_project() 93 | self.update_ui() 94 | 95 | self.Bind(wx.EVT_CLOSE, self.OnClose) 96 | self.Bind(EVT_YKL_LAYOUT_UPDATE, self.OnUpdate) 97 | self.Bind(EVT_YKL_SOZAI_UPDATE, self.OnUpdate) 98 | self.Bind(EVT_YKL_SCENARIO_UPDATE, self.OnUpdate) 99 | 100 | def create_widgets(self): 101 | self.total_rect = wx.SplitterWindow(self, wx.ID_ANY, style=wx.SP_3DSASH | wx.SP_LIVE_UPDATE) 102 | self.total_rect.SetMinimumPaneSize(100) 103 | 104 | self.upper_rect = wx.SplitterWindow(self.total_rect, wx.ID_ANY, style=wx.SP_3DSASH | wx.SP_LIVE_UPDATE) 105 | self.upper_rect.SetMinimumPaneSize(100) 106 | 107 | self.layout_panel = YKLLayoutPanel(self.upper_rect, wx.ID_ANY, self.ctx) 108 | self.sozai_panel = YKLSozaiPanel(self.upper_rect, wx.ID_ANY, self.ctx) 109 | 110 | self.upper_rect.SplitVertically(self.layout_panel, self.sozai_panel, sashPosition=700) 111 | 112 | self.scenario_panel = YKLScenarioPanel(self.total_rect, wx.ID_ANY, self.ctx) 113 | 114 | self.total_rect.SplitHorizontally(self.upper_rect, self.scenario_panel, sashPosition=380) 115 | 116 | def create_menu(self): 117 | menubar = wx.MenuBar() 118 | file_ = wx.Menu() 119 | 120 | new_item = wx.MenuItem(file_, wx.ID_NEW, "プロジェクトを新規作成\tCtrl+N") 121 | self.Bind(wx.EVT_MENU, self.OnNewProject, id=new_item.GetId()) 122 | file_.Append(new_item) 123 | 124 | open_item = wx.MenuItem(file_, wx.ID_OPEN, "プロジェクトを開く\tCtrl+O") 125 | self.Bind(wx.EVT_MENU, self.OnOpen, id=open_item.GetId()) 126 | file_.Append(open_item) 127 | 128 | file_.AppendSeparator() 129 | 130 | pro_set = wx.MenuItem(file_, wx.ID_ANY, "プロジェクト設定\tCtrl+P") 131 | self.Bind(wx.EVT_MENU, self.OnProjectSetting, id=pro_set.GetId()) 132 | file_.Append(pro_set) 133 | 134 | file_.AppendSeparator() 135 | 136 | save = wx.MenuItem(file_, wx.ID_SAVE, "プロジェクトを保存\tCtrl+S") 137 | self.Bind(wx.EVT_MENU, self.OnSave, id=save.GetId()) 138 | file_.Append(save) 139 | 140 | file_.AppendSeparator() 141 | 142 | self.audio_imp = wx.MenuItem(file_, wx.ID_ANY, "音声ファイルパスをインポート\tCtrl+I") 143 | self.audio_imp.Enable(False) 144 | self.Bind(wx.EVT_MENU, self.OnImportAudio, id=self.audio_imp.GetId()) 145 | file_.Append(self.audio_imp) 146 | 147 | about = wx.MenuItem(file_, wx.ID_ABOUT, "&YukkuLipsについて") 148 | self.Bind(wx.EVT_MENU, self.ShowAboutDialog, id=about.GetId()) 149 | file_.Append(about) 150 | 151 | close = wx.MenuItem(file_, wx.ID_EXIT, "&YukkuLipsを終了") 152 | self.Bind(wx.EVT_MENU, self.OnMenuClose, id=close.GetId()) 153 | file_.Append(close) 154 | 155 | edit = wx.Menu() 156 | 157 | self.scenario = wx.MenuItem(edit, wx.ID_ANY, "シナリオ編集\tCtrl+D") 158 | self.scenario.Enable(False) 159 | self.Bind(wx.EVT_MENU, self.OnScenarioEdit, id=self.scenario.GetId()) 160 | edit.Append(self.scenario) 161 | 162 | help_ = wx.Menu() 163 | 164 | to_repo = wx.MenuItem(help_, wx.ID_ANY, "YukkuLips Repository を開く") 165 | self.Bind(wx.EVT_MENU, self.OnOpenRepoURL, id=to_repo.GetId()) 166 | help_.Append(to_repo) 167 | 168 | to_lib = wx.MenuItem(help_, wx.ID_ANY, "使用ライブラリ") 169 | self.Bind(wx.EVT_MENU, self.OnOpenLibraries, id=to_lib.GetId()) 170 | help_.Append(to_lib) 171 | 172 | menubar.Append(file_, "&ファイル") 173 | menubar.Append(edit, "&編集") 174 | menubar.Append(help_, "&Help") 175 | self.SetMenuBar(menubar) 176 | 177 | def OnOpenRepoURL(self, event): 178 | webbrowser.open(YKL_REPOSITORY_URL) 179 | 180 | def OnOpenLibraries(self, event): 181 | webbrowser.open(YKL_LIBRARIES_URL) 182 | 183 | def OnProjectSetting(self, event): 184 | with YKLProjectEditDialog(self, self.ctx) as e_dialog: 185 | ret = e_dialog.ShowModal() 186 | if not (ret == wx.ID_CANCEL or ret == wx.ID_CLOSE): 187 | # scene_imageの更新はcontextのsetterによって行われている 188 | self.update_ui() 189 | 190 | def OnScenarioEdit(self, event): 191 | with YKLScriptEditDialog(self, self.ctx) as e_dialog: 192 | # ret = e_dialog.ShowModal() 193 | # if not (ret == wx.ID_CANCEL or ret == wx.ID_CLOSE): 194 | e_dialog.ShowModal() 195 | # scene_imageの更新はcontextのsetterによって行われている 196 | self.update_ui() 197 | 198 | def OnImportAudio(self, event): 199 | with YKLImportAudioDialog(self, self.ctx) as e_dialog: 200 | e_dialog.ShowModal() 201 | self.update_ui() 202 | 203 | def OnClose(self, event): 204 | if self.close_cancel(): 205 | return 206 | self.Destroy() 207 | event.Skip() 208 | 209 | def OnMenuClose(self, event): 210 | self.Close() 211 | 212 | def close_cancel(self): 213 | if self.ctx_created: 214 | if not self.ctx.project_saved(): 215 | if wx.MessageBox("プロジェクトが保存されていません。終了しますか?", 216 | "確認", wx.ICON_QUESTION | wx.YES_NO, 217 | self) == wx.NO: 218 | return True 219 | return False 220 | 221 | def OnOpen(self, event): 222 | if not self.ctx.project_saved(): 223 | if wx.MessageBox("保存していない内容は失われます。続行しますか?", 224 | "確認", wx.ICON_QUESTION | wx.YES_NO, 225 | self) == wx.NO: 226 | return 227 | with wx.DirDialog(self, 'プロジェクトフォルダを選択') as dir_dialog: 228 | if dir_dialog.ShowModal() == wx.ID_CANCEL: 229 | self.Close() 230 | path = dir_dialog.GetPath() 231 | if not (Path(path) / "project.json").exists(): 232 | wx.MessageBox("指定フォルダ内にproject.jsonがありません", 233 | "確認", wx.ICON_EXCLAMATION | wx.OK) 234 | return 235 | self.ctx = YKLContext(path) 236 | self.ctx.open_project() 237 | # 各UIパネルに関連づけられているコンテキストオブジェクトを更新 238 | self.scenario_panel.set_context(self.ctx) 239 | self.sozai_panel.set_context(self.ctx) 240 | self.layout_panel.set_context(self.ctx) 241 | self.update_ui() 242 | 243 | def OnNewProject(self, event): 244 | if not self.ctx.project_saved(): 245 | if wx.MessageBox("保存していない内容は失われます。続行しますか?", 246 | "確認", wx.ICON_QUESTION | wx.YES_NO, 247 | self) == wx.NO: 248 | return 249 | with YKLCreateProjectDialog(self) as create_dialog: 250 | if create_dialog.ShowModal() == wx.ID_CANCEL: 251 | return 252 | path = create_dialog.get_path() 253 | self.ctx = YKLContext(path) 254 | self.scenario_panel.set_context(self.ctx) 255 | self.sozai_panel.set_context(self.ctx) 256 | self.layout_panel.set_context(self.ctx) 257 | self.update_ui() 258 | 259 | def OnSave(self, event): 260 | self.ctx.save_project() 261 | self.update_title() 262 | 263 | def ShowAboutDialog(self, event): 264 | info = ADV.AboutDialogInfo() 265 | info.SetName("YukkuLips") 266 | info.SetVersion(VERSION) 267 | info.SetDescription( 268 | "クロマキー合成用キャラ素材動画生成アプリケーション\n\n" 269 | "スペシャルサンクス\n" 270 | "ズーズ氏 (http://www.nicotalk.com/charasozai.html)\n" 271 | "きつね氏 (http://www.nicotalk.com/ktykroom.html)\n\n" 272 | "YukkuLips Repository: https://github.com/PickledChair/YukkuLips") 273 | info.SetCopyright("(c) 2018 - 2020 SuitCase ") 274 | info.SetLicence(__doc__) 275 | 276 | ADV.AboutBox(info) 277 | 278 | def OnUpdate(self, event): 279 | self.update_ui() 280 | 281 | def update_title(self): 282 | if not self.ctx.project_saved(): 283 | self.SetTitle("YukkuLips: " + self.ctx.get_project_name() + " (未保存)") 284 | else: 285 | self.SetTitle("YukkuLips: " + self.ctx.get_project_name()) 286 | 287 | def update_ui(self): 288 | if len(self.ctx.get_sceneblocks()) == 0: 289 | self.scenario.Enable(False) 290 | self.audio_imp.Enable(False) 291 | else: 292 | self.scenario.Enable(True) 293 | self.audio_imp.Enable(True) 294 | self.sozai_panel.update_sozai_list() 295 | self.scenario_panel.update_sceneblock_list() 296 | self.layout_panel.update_layoutpreview() 297 | self.update_title() 298 | # self.Refresh() 299 | 300 | 301 | 302 | class YKLApp(wx.App): 303 | def __init__(self, *args, **kwargs): 304 | super().__init__(*args, **kwargs) 305 | 306 | def OnInit(self): 307 | self.SetAppName("YukkuLips") 308 | YKLAppWindow(None, wx.ID_ANY, "YukkuLips") 309 | 310 | return True 311 | 312 | 313 | def main(): 314 | app = YKLApp() 315 | app.MainLoop() 316 | 317 | 318 | if __name__ == "__main__": 319 | main() 320 | -------------------------------------------------------------------------------- /ykl_ui/sozai_edit_dialog.py: -------------------------------------------------------------------------------- 1 | """ 2 | yukkulips 3 | 4 | copyright (c) 2018 - 2020 suitcase 5 | 6 | this software is released under the mit license. 7 | https://github.com/pickledchair/yukkulips/blob/master/license.txt 8 | """ 9 | 10 | import wx 11 | import wx.lib.newevent as NE 12 | 13 | from media_utility.image_tool import get_thumbnail, open_img 14 | 15 | YKLSozaiEditUpdate, EVT_SOZAI_EDIT_UPDATE = NE.NewCommandEvent() 16 | 17 | 18 | class YKLSozaiEditDialog(wx.Dialog): 19 | def __init__(self, parent, ctx, sozai): 20 | super().__init__(parent, title='キャラ素材編集', size=(800, 680), style=wx.RESIZE_BORDER | wx.CAPTION | wx.CLOSE_BOX) 21 | self.ctx = ctx 22 | self.sozai = sozai 23 | 24 | whole_vbox = wx.BoxSizer(wx.VERTICAL) 25 | hbox = wx.BoxSizer(wx.HORIZONTAL) 26 | lvbox = wx.BoxSizer(wx.VERTICAL) 27 | pplabel = wx.StaticText(self, wx.ID_ANY, "プレビュー:") 28 | lvbox.Add(pplabel, flag=wx.ALL, border=5) 29 | self.ppanel = PreviewPanel(self, wx.ID_ANY, sozai.get_image()) 30 | lvbox.Add(self.ppanel, 1, flag=wx.EXPAND | wx.ALL, border=5) 31 | hbox.Add(lvbox, 1, flag=wx.EXPAND) 32 | 33 | # ダイアログ右側 34 | vbox = wx.BoxSizer(wx.VERTICAL) 35 | 36 | # 名前の表示・編集 37 | name_box = wx.BoxSizer(wx.HORIZONTAL) 38 | name_label = wx.StaticText(self, wx.ID_ANY, "名前:") 39 | name_box.Add(name_label, flag=wx.ALIGN_CENTER_VERTICAL) 40 | self.name = wx.TextCtrl(self, wx.ID_ANY, sozai.get_name()) 41 | name_box.Add(self.name, flag=wx.ALIGN_CENTER_VERTICAL) 42 | vbox.Add(name_box, 0, flag=wx.EXPAND | wx.ALL, border=5) 43 | 44 | # 2行目 45 | total_box = wx.BoxSizer(wx.HORIZONTAL) 46 | mirror_check = wx.CheckBox(self, wx.ID_ANY, "画像を左右反転") 47 | if self.sozai.is_mirror_img(): 48 | mirror_check.SetValue(True) 49 | self.Bind(wx.EVT_CHECKBOX, self.OnMirrorChecked, id=mirror_check.GetId()) 50 | total_box.Add(mirror_check, flag=wx.ALIGN_CENTER_VERTICAL) 51 | save_btn = wx.Button(self, wx.ID_ANY, "画像を保存") 52 | self.Bind(wx.EVT_BUTTON, self.OnSaveBtnClick, id=save_btn.GetId()) 53 | total_box.Add(save_btn, flag=wx.LEFT | wx.ALIGN_CENTER_VERTICAL, border=20) 54 | vbox.Add(total_box, 0, flag=wx.EXPAND | wx.ALL, border=5) 55 | 56 | anime_panel = AnimeSeriesPanel(self, wx.ID_ANY, self.sozai) 57 | vbox.Add(anime_panel, 1, flag=wx.EXPAND) 58 | 59 | vbox.Layout() 60 | hbox.Add(vbox) 61 | hbox.Layout() 62 | whole_vbox.Add(hbox) 63 | btns = wx.BoxSizer(wx.HORIZONTAL) 64 | cancel_btn = wx.Button(self, wx.ID_CANCEL, "キャンセル") 65 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=cancel_btn.GetId()) 66 | btns.Add(cancel_btn, flag=wx.ALL, border=5) 67 | ok_btn = wx.Button(self, wx.ID_OK, "適用して閉じる") 68 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=ok_btn.GetId()) 69 | btns.Add(ok_btn, flag=wx.ALL, border=5) 70 | btns.Layout() 71 | 72 | whole_vbox.Add(btns, 0, flag=wx.RIGHT | wx.ALIGN_RIGHT, border=5) 73 | self.SetSizer(whole_vbox) 74 | 75 | self.Bind(EVT_SOZAI_EDIT_UPDATE, self.OnSozaiEditUpdate) 76 | 77 | def OnMirrorChecked(self, event): 78 | if event.IsChecked(): 79 | self.sozai.set_mirror_img(True) 80 | else: 81 | self.sozai.set_mirror_img(False) 82 | # self.ppanel.set_image(self.sozai.get_image()) 83 | wx.PostEvent(self, YKLSozaiEditUpdate(self.GetId())) 84 | 85 | def OnSaveBtnClick(self, event): 86 | with wx.FileDialog( 87 | self, "キャラ素材画像を保存", str(self.ctx.get_project_path()), self.sozai.get_name() + ".png", 88 | wildcard="PNG files (*.png)|*.png", style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT 89 | ) as fileDialog: 90 | if fileDialog.ShowModal() == wx.ID_CANCEL: 91 | return 92 | path = fileDialog.GetPath() 93 | image = self.sozai.get_image() 94 | image.save(path, quality=95) 95 | 96 | def OnSozaiEditUpdate(self, event): 97 | self.ppanel.set_image(self.sozai.get_image()) 98 | 99 | def OnBtnClick(self, event): 100 | name = self.name.GetValue() 101 | self.sozai.set_name(name) 102 | self.EndModal(event.GetId()) 103 | 104 | 105 | class PreviewPanel(wx.ScrolledWindow): 106 | def __init__(self, parent, idx, image): 107 | super().__init__(parent, idx, size=(400, 500)) 108 | self.max_width = image.size[0] 109 | self.max_height = image.size[1] 110 | hbox = wx.BoxSizer(wx.HORIZONTAL) 111 | bmp = wx.Bitmap.FromBufferRGBA(*image.size, image.tobytes()) 112 | self.sbmp = wx.StaticBitmap(self, wx.ID_ANY, bmp) 113 | hbox.Add(self.sbmp) 114 | hbox.Layout() 115 | self.SetSizer(hbox) 116 | self.SetVirtualSize(self.max_width, self.max_height) 117 | self.SetScrollRate(20, 20) 118 | 119 | def set_image(self, image): 120 | bmp = wx.Bitmap.FromBufferRGBA(*image.size, image.tobytes()) 121 | self.sbmp.SetBitmap(bmp) 122 | size = bmp.GetSize() 123 | self.max_width = size.Width 124 | self.max_height = size.Height 125 | self.SetVirtualSize(self.max_width, self.max_height) 126 | self.Refresh() 127 | 128 | 129 | class AnimeSeriesPanel(wx.Panel): 130 | def __init__(self, parent, idx, sozai, size=(400, 550)): 131 | super().__init__(parent, idx, size=size) 132 | self.sozai = sozai 133 | self.part_name = None 134 | self.series_name = None 135 | self.front_back = None 136 | self.front_indices = [] 137 | self.front_series = None 138 | self.back_indices = [] 139 | self.back_series = None 140 | 141 | vbox = wx.BoxSizer(wx.VERTICAL) 142 | _title = wx.StaticText(self, wx.ID_ANY, "パーツ選択:") 143 | vbox.Add(_title, flag=wx.ALL, border=5) 144 | self.tree = wx.TreeCtrl(self, wx.ID_ANY, size=(380, 200)) 145 | self.Bind(wx.EVT_TREE_SEL_CHANGED, self.OnTreeSelChanged, id=self.tree.GetId()) 146 | vbox.Add(self.tree, 1, flag=wx.ALL, border=5) 147 | 148 | self.imgs_list = wx.ListCtrl(self, wx.ID_ANY, style=wx.LC_ICON, size=(380, 300)) 149 | self.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnListItemSelected, id=self.imgs_list.GetId()) 150 | self.Bind(wx.EVT_LIST_ITEM_DESELECTED, self.OnListItemDeselected, id=self.imgs_list.GetId()) 151 | self.il = wx.ImageList(200, 200) 152 | self.imgs_list.SetImageList(self.il, wx.IMAGE_LIST_NORMAL) 153 | vbox.Add(self.imgs_list, 1, flag=wx.ALL | wx.EXPAND, border=5) 154 | 155 | vbox.Layout() 156 | self.SetSizer(vbox) 157 | 158 | self.__set_parts_dic() 159 | 160 | # 初期値の設定 161 | current_u = self.sozai.get_current_image_dic().get("後") 162 | if current_u: 163 | paths_dic = self.sozai.get_parts_path_dic() 164 | f_serieses = paths_dic["後"]["前側"] 165 | b_serieses = paths_dic["後"]["後側"] 166 | for f_paths in f_serieses.values(): 167 | self.front_indices = [f_paths.index(path) for path in current_u if path in f_paths] 168 | if self.front_indices: 169 | break 170 | for b_paths in b_serieses.values(): 171 | self.back_indices = [b_paths.index(path) for path in current_u if path in b_paths] 172 | if self.back_indices: 173 | break 174 | # f_paths, u_pathsはforループ外でも生きているようだ 175 | current_f = [path for path in current_u if path in f_paths] 176 | if current_f: 177 | self.front_series = current_f[0].name[:2] 178 | current_b = [path for path in current_u if path in b_paths] 179 | if current_b: 180 | self.back_series = current_b[0].name[:2] 181 | 182 | def OnTreeSelChanged(self, event): 183 | _item = event.GetItem() 184 | if _item.IsOk(): 185 | i_name = self.tree.GetItemText(_item) 186 | parent = self.tree.GetItemParent(_item) 187 | if parent.IsOk(): 188 | p_name = self.tree.GetItemText(parent) 189 | if p_name not in ["基底", "後"]: 190 | paths_dic = self.sozai.get_parts_path_dic() 191 | if p_name in ["前側", "後側"]: 192 | self.part_name = "後" 193 | self.series_name = i_name 194 | self.front_back = p_name 195 | self.__set_imgs("後", i_name, u_or_m=p_name) 196 | current_p = self.sozai.get_current_image_dic()[self.part_name] 197 | for path in current_p: 198 | u_paths_list = paths_dic[self.part_name][self.front_back][self.series_name] 199 | if path in u_paths_list: 200 | idx = u_paths_list.index(path) 201 | self.imgs_list.Select(idx) 202 | else: 203 | self.part_name = p_name 204 | self.series_name = i_name 205 | self.__set_imgs(p_name, i_name) 206 | current_p = self.sozai.get_current_image_dic()[self.part_name] 207 | for path in current_p: 208 | p_paths_list = paths_dic[self.part_name][self.series_name] 209 | if path in p_paths_list: 210 | idx = p_paths_list.index(path) 211 | self.imgs_list.Select(idx) 212 | else: 213 | self.__set_imgs(None, None) 214 | self.Refresh() 215 | 216 | def OnListItemSelected(self, event): 217 | idx = event.Index 218 | if self.part_name == "後": 219 | if self.front_back == "前側": 220 | self.front_indices = [idx,] 221 | self.front_series = self.series_name 222 | if self.series_name != self.back_series: 223 | self.back_indices = [] 224 | self.back_series = None 225 | else: 226 | self.back_indices = [idx,] 227 | self.back_series = self.series_name 228 | if self.series_name != self.front_series: 229 | self.front_indices = [] 230 | self.front_series = None 231 | print(self.back_indices, self.front_indices) 232 | self.sozai.set_part_paths(self.part_name, self.series_name, self.back_indices, self.front_indices) 233 | else: 234 | self.sozai.set_part_paths(self.part_name, self.series_name, [idx,]) 235 | self.__set_treeitems_bold() 236 | wx.PostEvent(self, YKLSozaiEditUpdate(self.GetId())) 237 | 238 | def OnListItemDeselected(self, event): 239 | if self.part_name == '後': 240 | if self.front_back == "前側": 241 | self.front_indices = [] 242 | self.front_series = None 243 | else: 244 | self.back_indices = [] 245 | self.back_series = None 246 | self.sozai.set_part_paths(self.part_name, self.series_name, self.back_indices, self.front_indices) 247 | else: 248 | self.sozai.set_part_paths(self.part_name, self.series_name, []) 249 | self.__set_treeitems_bold() 250 | wx.PostEvent(self, YKLSozaiEditUpdate(self.GetId())) 251 | 252 | def __set_parts_dic(self): 253 | self.root = self.tree.AddRoot("基底") 254 | 255 | for part, series in self.sozai.get_parts_path_dic().items(): 256 | part_node = self.tree.AppendItem(self.root, part) 257 | 258 | for s, _item in series.items(): 259 | s_node = self.tree.AppendItem(part_node, s) 260 | 261 | if part == "後": 262 | for us, _uitem in _item.items(): 263 | self.tree.AppendItem(s_node, us) 264 | self.tree.Expand(self.root) 265 | p1, _ = self.tree.GetFirstChild(self.root) 266 | self.tree.Expand(p1) 267 | s1, _ = self.tree.GetFirstChild(p1) 268 | 269 | self.__set_treeitems_bold() 270 | self.tree.SelectItem(s1) 271 | 272 | def __set_treeitems_bold(self): 273 | current_image_dic = self.sozai.get_current_image_dic() 274 | 275 | part, _ = self.tree.GetFirstChild(self.root) 276 | while part.IsOk(): 277 | part_name = self.tree.GetItemText(part) 278 | if part_name == "後": 279 | um, _ = self.tree.GetFirstChild(part) 280 | for _ in range(2): 281 | series, _ = self.tree.GetFirstChild(um) 282 | while series.IsOk(): 283 | s_name = self.tree.GetItemText(series) 284 | paths = current_image_dic[part_name] 285 | if s_name in [path.name[:len(s_name)] for path in paths]: 286 | self.tree.SetItemBold(series) 287 | else: 288 | self.tree.SetItemBold(series, bold=False) 289 | series = self.tree.GetNextSibling(series) 290 | um = self.tree.GetNextSibling(um) 291 | else: 292 | series, _ = self.tree.GetFirstChild(part) 293 | while series.IsOk(): 294 | s_name = self.tree.GetItemText(series) 295 | paths = current_image_dic[part_name] 296 | if s_name in [path.name[:len(s_name)] for path in paths]: 297 | self.tree.SetItemBold(series) 298 | else: 299 | self.tree.SetItemBold(series, bold=False) 300 | series = self.tree.GetNextSibling(series) 301 | part = self.tree.GetNextSibling(part) 302 | 303 | def __set_imgs(self, part, series, u_or_m='後側'): 304 | self.imgs_list.DeleteAllItems() 305 | self.il.RemoveAll() 306 | if part is None: 307 | return 308 | if part == "後": 309 | paths = self.sozai.get_parts_path_dic()[part][u_or_m][series] 310 | for path in paths: 311 | image = open_img(path) 312 | image = get_thumbnail(image, size=200) 313 | bmp = wx.Bitmap.FromBufferRGBA(200, 200, image.tobytes()) 314 | self.il.Add(bmp) 315 | for i, path in enumerate(paths): 316 | self.imgs_list.InsertItem(i, path.name, i) 317 | else: 318 | paths = self.sozai.get_parts_path_dic()[part][series] 319 | for path in paths: 320 | image = open_img(path) 321 | image = get_thumbnail(image, size=200) 322 | bmp = wx.Bitmap.FromBufferRGBA(200, 200, image.tobytes()) 323 | self.il.Add(bmp) 324 | for i, path in enumerate(paths): 325 | self.imgs_list.InsertItem(i, path.name, i) 326 | -------------------------------------------------------------------------------- /media_utility/video_tool.py: -------------------------------------------------------------------------------- 1 | """ 2 | yukkulips 3 | 4 | copyright (c) 2018 - 2020 suitcase 5 | 6 | this software is released under the mit license. 7 | https://github.com/pickledchair/yukkulips/blob/master/license.txt 8 | """ 9 | 10 | import subprocess 11 | from collections import namedtuple 12 | from pathlib import Path 13 | import sys 14 | 15 | from media_utility.audio_tool import get_sound_map, get_sound_length, gen_void_sound 16 | 17 | MovieSize = namedtuple("MovieSize", ("name", "size_tuple")) 18 | 19 | FFMPEG_PATH = Path.cwd() / "FFmpeg" / "ffmpeg" 20 | 21 | if getattr(sys, 'frozen', False): 22 | # frozen は PyInstaller でこのスクリプトが固められた時に sys に追加される属性 23 | # frozen が見つからない時は素の Python で実行している時なので False を返す 24 | bundle_dir = sys._MEIPASS 25 | FFMPEG_PATH = Path(bundle_dir) / "FFmpeg" / "ffmpeg" 26 | 27 | 28 | def save_video(cache_path, sound_file, save_file_path, ffmpeg_path): 29 | # 音声を動画の長さまで引き伸ばすオプションを-filter_complexで指定している 30 | # cf. https://superuser.com/questions/801547/ffmpeg-add-audio-but-keep-video-length-the-same-not-shortest 31 | p = subprocess.Popen([ffmpeg_path, "-f", "image2", "-pattern_type", "glob", "-framerate", "30", 32 | "-i", str(cache_path / "*.png"), 33 | "-i", str(sound_file), 34 | "-pix_fmt", "yuv420p", "-qscale", "0", 35 | "-filter_complex", "[1:0] apad", "-shortest", 36 | str(save_file_path)]) 37 | p.wait() 38 | 39 | 40 | def save_silent_video(cache_path, save_file_path, ffmpeg_path): 41 | sound_file = cache_path / "void_sound.wav" 42 | gen_void_sound(sound_file) 43 | p = subprocess.Popen([ffmpeg_path, "-f", "image2", "-pattern_type", "glob", "-framerate", "30", 44 | "-i", str(cache_path / "*.png"), 45 | "-i", str(sound_file), 46 | "-pix_fmt", "yuv420p", "-qscale", "0", 47 | "-filter_complex", "[1:0] apad", "-shortest", 48 | str(save_file_path)]) 49 | p.wait() 50 | 51 | def make_mv_list_txt(txt_path, mv_path_list): 52 | txt = "" 53 | for mv_path in mv_path_list: 54 | txt += "file " + str(mv_path) + "\n" 55 | with open(txt_path, "w") as f: 56 | f.write(txt) 57 | 58 | def concat_movies(ffmpeg_path, mv_list_txt_path, save_path): 59 | p = subprocess.Popen([ffmpeg_path, "-f", "concat", "-safe", "0", "-i", str(mv_list_txt_path), 60 | "-c:v", "copy", "-c:a", "copy", "-c:s", "copy", 61 | "-map", "0:v", "-map", "0:a", "-map", "0:s?", 62 | str(save_path)]) 63 | 64 | 65 | from enum import Enum, auto 66 | 67 | class AnimeType(Enum): 68 | NOTHING = auto() # なし 69 | VOLUME_FOLLOW = auto() # 台詞音量型 70 | CYCLE_ROUND = auto() # 定期往復型 71 | CYCLE_ONEWAY = auto() # 定期片道型 72 | COME_LEAVE = auto() # 登場退場型 73 | COME_ROUND = auto() # 登場往復型 74 | COME_ONEWAY = auto() # 登場片道型 75 | LEAVE_ROUND = auto() # 退場往復型 76 | LEAVE_ONEWAY = auto() # 退場片道型 77 | 78 | @staticmethod 79 | def get_labels(): 80 | return ["なし", 81 | "台詞音量型", 82 | "定期往復型", 83 | "定期片道型", 84 | "登場退場型", 85 | "登場往復型", 86 | "登場片道型", 87 | "退場往復型", 88 | "退場片道型"] 89 | 90 | @staticmethod 91 | def from_str(string): 92 | if string == "VOLUME_FOLLOW" or string == "台詞音量型": 93 | return AnimeType.VOLUME_FOLLOW 94 | elif string == "CYCLE_ROUND" or string == "定期往復型": 95 | return AnimeType.CYCLE_ROUND 96 | elif string == "CYCLE_ONEWAY" or string == "定期片道型": 97 | return AnimeType.CYCLE_ONEWAY 98 | elif string == "COME_LEAVE" or string == "登場退場型": 99 | return AnimeType.COME_LEAVE 100 | elif string == "COME_ROUND" or string == "登場往復型": 101 | return AnimeType.COME_ROUND 102 | elif string == "COME_ONEWAY" or string == "登場片道型": 103 | return AnimeType.COME_ONEWAY 104 | elif string == "LEAVE_ROUND" or string == "退場往復型": 105 | return AnimeType.LEAVE_ROUND 106 | elif string == "LEAVE_ONEWAY" or string == "退場片道型": 107 | return AnimeType.LEAVE_ONEWAY 108 | else: 109 | return AnimeType.NOTHING 110 | 111 | def get_anime_generator(self): 112 | if self.name == "VOLUME_FOLLOW": 113 | def gen_volume_follow(sound_file, imgs_num, threshold, line, sizing): 114 | if sound_file: 115 | sound_level_seq = get_sound_map( 116 | sound_file, imgs_num, 117 | threshold_ratio=threshold, line=line, sizing=sizing) 118 | if sound_level_seq: 119 | for i in sound_level_seq: 120 | yield i 121 | while True: 122 | yield 0 123 | return gen_volume_follow 124 | elif self.name == "CYCLE_ROUND": 125 | def gen_cycle_round(start, imgs_num, take_time, hold, interval, length): 126 | _count = 0 127 | for _ in range(int(start*30)): 128 | _count += 1 129 | yield 0 130 | anime_seq_up = [round((imgs_num-1)/(take_time/2*30)*i) for i in range(int(take_time/2*30))] 131 | anime_seq_down = list(reversed(anime_seq_up)) 132 | status = "interval" 133 | anime_frame_num = len(anime_seq_up)*2 + hold 134 | while True: 135 | if status == "up": 136 | if length - _count < anime_frame_num: 137 | status = "interval" 138 | continue 139 | for i in anime_seq_up: 140 | _count += 1 141 | yield i 142 | status = "hold" 143 | elif status == "hold": 144 | for _ in range(hold): 145 | _count += 1 146 | yield imgs_num - 1 147 | status = "down" 148 | elif status == "down": 149 | for i in anime_seq_down: 150 | _count += 1 151 | yield i 152 | status = "interval" 153 | elif status == "interval": 154 | for _ in range(int(interval*30) - anime_frame_num): 155 | _count += 1 156 | yield 0 157 | status = "up" 158 | return gen_cycle_round 159 | elif self.name == "CYCLE_ONEWAY": 160 | def gen_cycle_oneway(start, imgs_num, take_time, hold, interval, length): 161 | _count = 0 162 | for _ in range(int(start*30)): 163 | _count += 1 164 | yield 0 165 | anime_seq = [round((imgs_num-1)/(take_time*30)*i) for i in range(int(take_time*30))] 166 | status = "interval" 167 | anime_frame_num = len(anime_seq) + hold 168 | while True: 169 | if status == "interval": 170 | for _ in range(int(interval*30)-anime_frame_num): 171 | _count += 1 172 | yield 0 173 | status = "up" 174 | elif status == "up": 175 | if length - _count < anime_frame_num: 176 | status = "interval" 177 | continue 178 | for i in anime_seq: 179 | _count += 1 180 | yield i 181 | status = "hold" 182 | elif status == "hold": 183 | for _ in range(hold): 184 | _count += 1 185 | yield imgs_num - 1 186 | status = "interval" 187 | return gen_cycle_oneway 188 | elif self.name == "COME_LEAVE": 189 | def gen_come_leave(start, imgs_num, take_time, length): 190 | for _ in range(int(start)*30): 191 | yield 0 192 | length -= 1 193 | anime_seq_up = [round((imgs_num-1)/(take_time*30)*i) for i in range(int(take_time*30))] 194 | anime_seq_down = list(reversed(anime_seq_up)) 195 | for i in anime_seq_up: 196 | yield i 197 | length -= 1 198 | for _ in range(length-len(anime_seq_down)): 199 | yield imgs_num - 1 200 | for i in anime_seq_down: 201 | yield i 202 | while True: 203 | yield 0 204 | return gen_come_leave 205 | elif self.name == "COME_ROUND": 206 | def gen_come_round(start, imgs_num, take_time, hold): 207 | for _ in range(int(start*30)): 208 | yield 0 209 | anime_seq_up = [round((imgs_num-1)/(take_time/2*30)*i) for i in range(int(take_time/2*30))] 210 | anime_seq_down = list(reversed(anime_seq_up)) 211 | for i in anime_seq_up: 212 | yield i 213 | for _ in range(hold): 214 | yield imgs_num - 1 215 | for i in anime_seq_down: 216 | yield i 217 | while True: 218 | yield 0 219 | return gen_come_round 220 | elif self.name == "COME_ONEWAY": 221 | def gen_come_oneway(start, imgs_num, take_time): 222 | for _ in range(int(start*30)): 223 | yield 0 224 | anime_seq_up = [round((imgs_num-1)/(take_time*30)*i) for i in range(int(take_time*30))] 225 | for i in anime_seq_up: 226 | yield i 227 | while True: 228 | yield imgs_num - 1 229 | return gen_come_oneway 230 | elif self.name == "LEAVE_ROUND": 231 | def gen_leave_round(start, imgs_num, take_time, hold, length): 232 | for _ in range(int(start*30)): 233 | yield 0 234 | length -= 1 235 | anime_seq_up = [round((imgs_num-1)/(take_time/2*30)*i) for i in range(int(take_time/2*30))] 236 | anime_seq_down = list(reversed(anime_seq_up)) 237 | for _ in range(length-(len(anime_seq_up)*2+hold)): 238 | yield 0 239 | for i in anime_seq_up: 240 | yield i 241 | for _ in range(hold): 242 | yield imgs_num - 1 243 | for i in anime_seq_down: 244 | yield i 245 | while True: 246 | yield 0 247 | return gen_leave_round 248 | elif self.name == "LEAVE_ONEWAY": 249 | def gen_leave_oneway(start, imgs_num, take_time, length): 250 | for _ in range(int(start*30)): 251 | yield 0 252 | length -= 1 253 | anime_seq_up = [round((imgs_num-1)/(take_time*30)*i) for i in range(int(take_time*30))] 254 | for _ in range(length-len(anime_seq_up)): 255 | yield 0 256 | for i in anime_seq_up: 257 | yield i 258 | while True: 259 | yield imgs_num - 1 260 | return gen_leave_oneway 261 | else: 262 | def gen_nothing(): 263 | while True: 264 | yield 0 265 | return gen_nothing 266 | 267 | class LineShape(Enum): 268 | LINEAR = auto() 269 | QUADRATIC = auto() 270 | 271 | @staticmethod 272 | def from_str(string): 273 | if string == "LINEAR" or string == "一次直線": 274 | return LineShape.LINEAR 275 | else: 276 | return LineShape.QUADRATIC 277 | 278 | def get_funcs(self): 279 | if self.name == "LINEAR": 280 | return lambda x: x / 2, lambda x: abs(x) 281 | elif self.name == "QUADRATIC": 282 | return lambda x: x ** 2 / 2, lambda x: x ** 2 283 | 284 | class PartAnimeSetting: 285 | def __init__(self, partname, imgs_num): 286 | self.part = partname 287 | self.anime_type = AnimeType.NOTHING 288 | self.imgs_num = imgs_num 289 | self.start = 0.0 290 | self.interval = 5.0 291 | self.take_time = 0.5 292 | self.stop_frames = 2 293 | self.sound_threshold = 1.0 294 | self.line_shape = LineShape.LINEAR 295 | 296 | def get_gen_obj(self, length=30*60, sound=None): 297 | # print(self.anime_type) 298 | if self.anime_type == AnimeType.NOTHING: 299 | return self.anime_type.get_anime_generator()() 300 | elif self.anime_type == AnimeType.VOLUME_FOLLOW: 301 | line, sizing = self.line_shape.get_funcs() 302 | return self.anime_type.get_anime_generator()(sound, self.imgs_num, self.sound_threshold, line, sizing) 303 | elif self.anime_type == AnimeType.CYCLE_ROUND: 304 | return self.anime_type.get_anime_generator()( 305 | self.start, self.imgs_num, self.take_time, self.stop_frames, self.interval, length) 306 | elif self.anime_type == AnimeType.CYCLE_ONEWAY: 307 | return self.anime_type.get_anime_generator()( 308 | self.start, self.imgs_num, self.take_time, self.stop_frames, self.interval, length) 309 | elif self.anime_type == AnimeType.COME_LEAVE: 310 | return self.anime_type.get_anime_generator()( 311 | self.start, self.imgs_num, self.take_time, length) 312 | elif self.anime_type == AnimeType.COME_ROUND: 313 | return self.anime_type.get_anime_generator()( 314 | self.start, self.imgs_num, self.take_time, self.stop_frames) 315 | elif self.anime_type == AnimeType.COME_ONEWAY: 316 | return self.anime_type.get_anime_generator()( 317 | self.start, self.imgs_num, self.take_time) 318 | elif self.anime_type == AnimeType.LEAVE_ROUND: 319 | return self.anime_type.get_anime_generator()( 320 | self.start, self.imgs_num, self.take_time, self.stop_frames, length) 321 | elif self.anime_type == AnimeType.LEAVE_ONEWAY: 322 | return self.anime_type.get_anime_generator()( 323 | self.start, self.imgs_num, self.take_time, length) 324 | 325 | class AnimeSetting: 326 | def __init__(self, anime_count_dict): 327 | self.part_settings = {part: PartAnimeSetting(part, _count) for part, _count in anime_count_dict.items()} 328 | 329 | def into_json(self): 330 | return {part.part: { 331 | "Anime Type": part.anime_type.name, 332 | "Start Time": part.start, 333 | "Interval": part.interval, 334 | "Take Time": part.take_time, 335 | "Hold Frame": part.stop_frames, 336 | "Threshold": part.sound_threshold, 337 | "Line Shape": part.line_shape.name, 338 | } for part in self.part_settings.values()} 339 | 340 | def get_anime_images_seq(self, part_order, images_dict, sound=None, take_time=1., suffix_time=0.): 341 | # print(part_order, list(self.part_settings.keys())) 342 | result = [] 343 | if sound: 344 | sound_anime_parts = [part for part in self.part_settings if self.part_settings[part].anime_type == AnimeType.VOLUME_FOLLOW] 345 | # 音源は指定されているが台詞音量型アニメが指定されていない時がある 346 | # その時、便宜的にadd_timeはsuffix_timeと同じ値を指定する 347 | if len(sound_anime_parts) == 0: 348 | add_time = suffix_time 349 | else: 350 | add_time = max([self.part_settings[part].start for part in sound_anime_parts]) + suffix_time 351 | length = get_sound_length(sound, add_time=add_time) 352 | gen_list = [self.part_settings[part].get_gen_obj(sound=sound, length=length) for part in part_order] 353 | image = images_dict 354 | for _ in range(length): 355 | for gen in gen_list: 356 | image = image[next(gen)] 357 | result.append(image) 358 | image = images_dict 359 | return result 360 | else: 361 | length = round((take_time+suffix_time)*30) 362 | gen_list = [self.part_settings[part].get_gen_obj(length=length) for part in part_order] 363 | image = images_dict 364 | for _ in range(length): 365 | for gen in gen_list: 366 | image = image[next(gen)] 367 | result.append(image) 368 | image = images_dict 369 | return result 370 | -------------------------------------------------------------------------------- /ykl_core/scene_block.py: -------------------------------------------------------------------------------- 1 | """ 2 | yukkulips 3 | 4 | copyright (c) 2018 - 2020 suitcase 5 | 6 | this software is released under the mit license. 7 | https://github.com/pickledchair/yukkulips/blob/master/license.txt 8 | """ 9 | 10 | import json 11 | from copy import deepcopy 12 | import shutil 13 | from multiprocessing import Manager, Pool, freeze_support 14 | freeze_support() 15 | 16 | import wx 17 | 18 | from .chara_sozai import CharaSozai 19 | from media_utility.image_tool import get_rescaled_image, get_scene_image, save_anime_frame 20 | from media_utility.audio_tool import get_sound_length, mix_movie_sounds 21 | from media_utility.video_tool import save_video, save_silent_video, FFMPEG_PATH 22 | 23 | 24 | class SceneBlock: 25 | def __init__(self, sb_uuid, sozai_resource_dic, project): 26 | self.__project = project 27 | self.__uuid = sb_uuid 28 | self.__sozai_resource_dic = sozai_resource_dic 29 | self.__sozai_dic = { 30 | k: CharaSozai( 31 | k, self.__uuid, self.__project, resource) 32 | for k, resource in sozai_resource_dic.items()} 33 | self.__sozai_pos = [(0, 0) for _ in sozai_resource_dic] 34 | self.__sozai_order = [i for i in range(len(sozai_resource_dic))] 35 | self.__sozai_scale = [1.0 for _ in range(len(sozai_resource_dic))] 36 | self.__bg_path = None 37 | self.__content = SceneBlock.__initial_content() 38 | if sozai_resource_dic: 39 | self.__content['CharaSozai List'] = list(self.__sozai_dic.keys()) 40 | self.__content['CharaSozai Pos'] = [list(pos) for pos in self.__sozai_pos] 41 | self.__content['CharaSozai Order'] = self.__sozai_order 42 | self.__content['CharaSozai Scale'] = self.__sozai_scale 43 | self.__scene_image = self.__integrate_scene_image() 44 | 45 | # 動画設定 46 | self.__movie_time = 1. 47 | self.__suffix_time = 0.5 48 | self.__movie_generated = False 49 | root = self.__project.root_path() / "scene_block" / self.__uuid 50 | movie_dir = root / "movie" 51 | self.__movie_path = movie_dir / "scene_movie.mp4" 52 | 53 | self.__is_saved = False 54 | 55 | @property 56 | def movie_path(self): 57 | if self.movie_generated: 58 | return self.__movie_path 59 | else: 60 | return None 61 | 62 | @movie_path.setter 63 | def movie_path(self, _): 64 | raise ValueError() 65 | 66 | @staticmethod 67 | def __initial_content(): 68 | content = { 69 | 'CharaSozai List': [], 70 | 'CharaSozai Pos': [], 71 | 'CharaSozai Order': [], 72 | 'CharaSozai Scale': [], 73 | 'Background Path': None, 74 | 'Movie Time': 1.0, 75 | 'Suffix Time': 0.5, 76 | 'Movie Generated': False, 77 | } 78 | return content 79 | 80 | @staticmethod 81 | def open(sb_uuid, sozai_resource_dic, project, parts_path_dics=[]): 82 | block = SceneBlock(sb_uuid, {}, project) 83 | with (project.root_path() / "scene_block" / sb_uuid / "sceneblock.json").open('r') as f: 84 | content = json.load(f) 85 | block.__restore(content, sozai_resource_dic, parts_path_dics) 86 | return block 87 | 88 | def __restore(self, content, sozai_resource_dic, parts_path_dics): 89 | sozai_list = content['CharaSozai List'] 90 | self.__sozai_resource_dic = sozai_resource_dic 91 | self.__sozai_dic = { 92 | s: CharaSozai.open( 93 | s, self.__uuid, self.__project, self.__sozai_resource_dic[s], parts_path_dic=p 94 | ) for s, p in zip(sozai_list, parts_path_dics) 95 | } 96 | self.__sozai_pos = [tuple(pos) for pos in content["CharaSozai Pos"]] 97 | self.__sozai_order = content["CharaSozai Order"] 98 | self.__sozai_scale = content["CharaSozai Scale"] 99 | self.__bg_path = content["Background Path"] 100 | self.__movie_time = content.get("Movie Time", 1.0) 101 | self.__suffix_time = content.get("Suffix Time", 0.5) 102 | self.__movie_generated = content.get("Movie Generated", False) 103 | self.__content = content 104 | self.__scene_image = self.__integrate_scene_image() 105 | self.__is_saved = True 106 | 107 | def add_sozai(self, k, resource_path): 108 | self.__sozai_dic[k] = CharaSozai(k, self.__uuid, self.__project, resource_path) 109 | self.__sozai_resource_dic[k] = resource_path 110 | self.__sozai_order.append(len(self.__sozai_dic)-1) 111 | self.__sozai_pos.append((0, 0)) 112 | self.__sozai_scale.append(1.0) 113 | self.__content['CharaSozai List'] = list(self.__sozai_dic.keys()) 114 | self.__content['CharaSozai Pos'] = [list(pos) for pos in self.__sozai_pos] 115 | self.__content['CharaSozai Order'] = self.__sozai_order 116 | self.__content['CharaSozai Scale'] = self.__sozai_scale 117 | self.__scene_image = self.__integrate_scene_image() 118 | self.movie_generated = False 119 | self.__is_saved = False 120 | 121 | def remove_sozai(self, idx): 122 | key = list(self.__sozai_dic.keys())[idx] 123 | self.__sozai_dic.pop(key) 124 | self.__sozai_resource_dic.pop(key) 125 | self.__sozai_order.pop(idx) 126 | self.__sozai_pos.pop(idx) 127 | self.__sozai_scale.pop(idx) 128 | self.__content['CharaSozai List'] = list(self.__sozai_dic.keys()) 129 | self.__content['CharaSozai Pos'] = [list(pos) for pos in self.__sozai_pos] 130 | self.__content['CharaSozai Order'] = self.__sozai_order 131 | self.__content['CharaSozai Scale'] = self.__sozai_scale 132 | self.__scene_image = self.__integrate_scene_image() 133 | self.movie_generated = False 134 | self.__is_saved = False 135 | 136 | def set_new_sozai(self, idx, sozai): 137 | key = list(self.__sozai_dic.keys())[idx] 138 | self.__sozai_dic[key] = sozai 139 | self.__scene_image = self.__integrate_scene_image() 140 | self.movie_generated = False 141 | self.__is_saved = False 142 | 143 | def sozais_unsaved_check(self): 144 | self.__is_saved = all([sozai.is_saved() for sozai in self.get_sozais()]) 145 | if not self.__is_saved: 146 | self.movie_generated = False 147 | 148 | def __integrate_scene_image(self): 149 | images = [sozai.get_image() for sozai in self.get_sozais()] 150 | # 整列はget_scene_image関数内で行うため、ここで並び替えてはいけない 151 | # images = [images[i] for i in self.__sozai_order] 152 | images = [get_rescaled_image(image, scale) for image, scale in zip(images, self.__sozai_scale)] 153 | bg_size = self.__project.resolution 154 | bg_color = self.__project.bg_color 155 | scene_image = get_scene_image(images, self.__sozai_pos, self.__sozai_order, bg_size=bg_size, bg_color=bg_color) 156 | return scene_image 157 | 158 | def update_scene_image(self): 159 | self.__scene_image = self.__integrate_scene_image() 160 | self.movie_generated = False 161 | self.__is_saved = False 162 | 163 | @property 164 | def sozai_pos(self): 165 | return self.__sozai_pos 166 | 167 | @sozai_pos.setter 168 | def sozai_pos(self, new_pos_list): 169 | self.__sozai_pos = new_pos_list 170 | self.__scene_image = self.__integrate_scene_image() 171 | self.__content["CharaSozai Pos"] = [list(pos) for pos in new_pos_list] 172 | self.movie_generated = False 173 | self.__is_saved = False 174 | 175 | @property 176 | def sozai_scale(self): 177 | return self.__sozai_scale 178 | 179 | @sozai_scale.setter 180 | def sozai_scale(self, new_scale_list): 181 | self.__sozai_scale = new_scale_list 182 | self.__scene_image = self.__integrate_scene_image() 183 | self.__content["CharaSozai Scale"] = new_scale_list 184 | self.movie_generated = False 185 | self.__is_saved = False 186 | 187 | @property 188 | def sozai_order(self): 189 | return self.__sozai_order 190 | 191 | @sozai_order.setter 192 | def sozai_order(self, new_order_list): 193 | self.__sozai_order = new_order_list 194 | self.__scene_image = self.__integrate_scene_image() 195 | self.__content["CharaSozai Order"] = new_order_list 196 | self.movie_generated = False 197 | self.__is_saved = False 198 | 199 | @property 200 | def bg_path(self): 201 | return self.__bg_path 202 | 203 | @bg_path.setter 204 | def bg_path(self, new_path): 205 | self.__bg_path = new_path 206 | self.__content["Background Path"] = str(new_path) 207 | self.__is_saved = False 208 | 209 | @property 210 | def scene_image(self): 211 | return self.__scene_image 212 | 213 | @scene_image.setter 214 | def scene_image(self, image): 215 | raise ValueError() 216 | 217 | @property 218 | def movie_generated(self): 219 | return self.__movie_generated 220 | 221 | @movie_generated.setter 222 | def movie_generated(self, b): 223 | self.__movie_generated = b 224 | self.__content["Movie Generated"] = self.__movie_generated 225 | self.__is_saved = False 226 | 227 | @property 228 | def movie_time(self): 229 | return self.__movie_time 230 | 231 | @movie_time.setter 232 | def movie_time(self, new_time): 233 | self.__movie_time = new_time 234 | self.__content["Movie Time"] = self.__movie_time 235 | self.movie_generated = False 236 | self.__is_saved = False 237 | 238 | @property 239 | def suffix_time(self): 240 | return self.__suffix_time 241 | 242 | @suffix_time.setter 243 | def suffix_time(self, new_time): 244 | self.__suffix_time = new_time 245 | self.__content["Suffix Time"] = self.__suffix_time 246 | self.movie_generated = False 247 | self.__is_saved = False 248 | 249 | def set_uuid(self, new_uuid): 250 | self.__uuid = new_uuid 251 | self.movie_generated = False 252 | self.__is_saved = False 253 | 254 | def get_uuid(self): 255 | return self.__uuid 256 | 257 | def get_copy(self, new_uuid): 258 | copy_self = deepcopy(self) 259 | copy_self.set_uuid(new_uuid) 260 | for sozai in copy_self.get_sozais(): 261 | sozai.set_sb_uuid(new_uuid) 262 | copy_self.movie_generated = False 263 | return copy_self 264 | 265 | def get_sozais(self): 266 | return list(self.__sozai_dic.values()) 267 | 268 | def get_sozai_resource_dic(self): 269 | return deepcopy(self.__sozai_resource_dic) 270 | 271 | def save(self): 272 | root = self.__project.root_path() / "scene_block" / self.__uuid 273 | sozai_folder = root / "chara_sozai" 274 | if not root.exists(): 275 | root.mkdir() 276 | sozai_folder.mkdir() 277 | else: 278 | if self.__is_saved: 279 | return 280 | else: 281 | # キャラ素材の増減を反映してフォルダを削除 282 | items = [item.name for item in sozai_folder.iterdir() if item.is_dir()] 283 | # print(items) 284 | delete_sozais = [item for item in items if item not in list(self.__sozai_dic.keys())] 285 | # print(delete_sozais) 286 | for delete_sozai in delete_sozais: 287 | shutil.rmtree(sozai_folder / delete_sozai) 288 | for sozai in self.get_sozais(): 289 | sozai.save() 290 | block_file_path = root / 'sceneblock.json' 291 | with block_file_path.open('w') as f: 292 | json.dump(self.__content, f, indent=4, ensure_ascii=False) 293 | self.__is_saved = True 294 | 295 | def is_saved(self): 296 | return self.__is_saved 297 | 298 | def get_movie_time(self): 299 | anime_sounds = [] 300 | for sozai in self.get_sozais(): 301 | _, anime_audio = sozai.get_movie_anime_audio() 302 | anime_sounds.append(anime_audio) 303 | sound_time = max([get_sound_length(path) for path in anime_sounds]) / 30 304 | return sound_time 305 | 306 | def generate_movie(self): 307 | if not self.__is_saved: 308 | self.save() 309 | self.__is_saved = False 310 | movie_sounds = [] 311 | anime_sounds = [] 312 | ordered_parts = [] 313 | anime_dicts = [] 314 | anime_settings = [] 315 | progress_dialog = wx.ProgressDialog( 316 | title="動画出力", 317 | message="動画出力中...", 318 | maximum=len(self.get_sozais())+1, 319 | style=wx.PD_APP_MODAL | wx.PD_SMOOTH | wx.PD_AUTO_HIDE) 320 | progress_dialog.Show() 321 | progress = 1 322 | progress_dialog.Update( 323 | progress, 324 | "キャラ素材ごとのアニメ画像生成: 開始中...") 325 | for sozai in self.get_sozais(): 326 | movie_audio, anime_audio = sozai.get_movie_anime_audio() 327 | movie_sounds.append(movie_audio) 328 | anime_sounds.append(anime_audio) 329 | anime_setting = sozai.create_anime_setting() 330 | anime_settings.append(anime_setting) 331 | soz = deepcopy(sozai) 332 | ordered_part, anime_dict = soz.get_anime_image_dict(image_elements=True) 333 | ordered_parts.append(ordered_part) 334 | anime_dicts.append(anime_dict) 335 | progress += 1 336 | progress_dialog.Update( 337 | progress, 338 | "キャラ素材ごとのアニメ画像生成: " 339 | "{}/{}".format(progress, len(self.get_sozais()))) 340 | sound_time = max([get_sound_length(path) for path in anime_sounds]) / 30 341 | imgs_seqs = [] 342 | progress_dialog = wx.ProgressDialog( 343 | title="動画出力", 344 | message="動画出力中...", 345 | maximum=len(anime_settings)+1, 346 | style=wx.PD_APP_MODAL | wx.PD_SMOOTH | wx.PD_AUTO_HIDE) 347 | progress_dialog.Show() 348 | progress = 1 349 | progress_dialog.Update( 350 | progress, 351 | "キャラ素材ごとのフレーム整列: 開始中...") 352 | if sound_time > 0: 353 | for a, p, i, s in zip(anime_settings, ordered_parts, anime_dicts, anime_sounds): 354 | sound = s if s else None 355 | imgs_seq = a.get_anime_images_seq(p, i, sound=sound, take_time=sound_time, suffix_time=self.__suffix_time) 356 | imgs_seqs.append(imgs_seq) 357 | progress += 1 358 | progress_dialog.Update( 359 | progress, 360 | "キャラ素材ごとのフレーム整列: " 361 | "{}/{}".format(progress, len(anime_settings))) 362 | else: 363 | for a, p, i in zip(anime_settings, ordered_parts, anime_dicts): 364 | imgs_seq = a.get_anime_images_seq(p, i, take_time=self.__movie_time, suffix_time=self.__suffix_time) 365 | imgs_seqs.append(imgs_seq) 366 | progress += 1 367 | progress_dialog.Update( 368 | progress, 369 | "キャラ素材ごとのフレーム整列: " 370 | "{}/{}".format(progress, len(anime_settings))) 371 | root = self.__project.root_path() / "scene_block" / self.__uuid 372 | images_dir = root / "images" 373 | sound_dir = root / "sound" 374 | movie_dir = root / "movie" 375 | if not images_dir.exists(): 376 | images_dir.mkdir() 377 | else: 378 | shutil.rmtree(images_dir) 379 | images_dir.mkdir() 380 | if not sound_dir.exists(): 381 | sound_dir.mkdir() 382 | else: 383 | shutil.rmtree(sound_dir) 384 | sound_dir.mkdir() 385 | if not movie_dir.exists(): 386 | movie_dir.mkdir() 387 | else: 388 | shutil.rmtree(movie_dir) 389 | movie_dir.mkdir() 390 | imgs_num = min([len(imgs) for imgs in imgs_seqs]) 391 | digit_num = len(str(imgs_num)) 392 | args = [] 393 | queue = Manager().Queue() 394 | progress_dialog = wx.ProgressDialog( 395 | title="動画出力", 396 | message="動画出力中...", 397 | maximum=imgs_num, 398 | style=wx.PD_APP_MODAL | wx.PD_SMOOTH | wx.PD_AUTO_HIDE) 399 | progress_dialog.Show() 400 | progress = 0 401 | for i in range(imgs_num): 402 | imgs = [] 403 | for seq in imgs_seqs: 404 | imgs.append(seq[i]) 405 | imgs = [get_rescaled_image(image, scale) for image, scale in zip(imgs, self.__sozai_scale)] 406 | bg_size = self.__project.resolution 407 | bg_color = self.__project.bg_color 408 | frame = get_scene_image(imgs, self.__sozai_pos, self.__sozai_order, bg_size=bg_size, bg_color=bg_color) 409 | args.append((frame, images_dir, i, digit_num, queue)) 410 | progress += 1 411 | progress_dialog.Update( 412 | progress, 413 | "全てのキャラ素材アニメーションを統合: " 414 | "{}/{}".format(progress, imgs_num)) 415 | 416 | p = Pool() 417 | r = p.map_async(save_anime_frame, args) 418 | 419 | progress_dialog = wx.ProgressDialog( 420 | title="動画出力", 421 | message="動画出力中...", 422 | maximum=len(args), 423 | style=wx.PD_APP_MODAL | wx.PD_SMOOTH | wx.PD_AUTO_HIDE) 424 | progress_dialog.Show() 425 | progress = 0 426 | 427 | while not r.ready() or progress < len(args): 428 | for _ in range(queue.qsize()): 429 | progress += 1 430 | progress_dialog.Update( 431 | progress, 432 | "連番画像をキャッシュに保存中: {}\n\t{}/{}" 433 | " フレーム".format(queue.get(), progress, len(args))) 434 | 435 | p.close() 436 | 437 | movie_path = movie_dir / "scene_movie.mp4" 438 | progress_dialog = wx.ProgressDialog( 439 | title="動画出力", 440 | message="動画出力中...", 441 | maximum=2, 442 | style=wx.PD_APP_MODAL | wx.PD_SMOOTH | wx.PD_AUTO_HIDE) 443 | progress_dialog.Show() 444 | progress = 1 445 | progress_dialog.Update(progress, "動画出力中...") 446 | if sound_time > 0: 447 | sound_path = mix_movie_sounds(movie_sounds, str(FFMPEG_PATH), sound_dir) 448 | while not sound_path.exists(): 449 | pass 450 | save_video(images_dir, sound_path, movie_path, str(FFMPEG_PATH)) 451 | else: 452 | save_silent_video(images_dir, movie_path, str(FFMPEG_PATH)) 453 | progress += 1 454 | progress_dialog.Update(progress, "完了") 455 | while not movie_path.exists(): 456 | pass 457 | shutil.rmtree(images_dir) 458 | shutil.rmtree(sound_dir) 459 | self.movie_generated = True 460 | self.__movie_path = movie_path 461 | return movie_path 462 | -------------------------------------------------------------------------------- /ykl_ui/layout_edit_dialog.py: -------------------------------------------------------------------------------- 1 | """ 2 | YukkuLips 3 | 4 | Copyright (c) 2018 - 2020 SuitCase 5 | 6 | This software is released under the MIT License. 7 | https://github.com/PickledChair/YukkuLips/blob/master/LICENSE.txt 8 | """ 9 | 10 | import wx 11 | import wx.lib.newevent as NE 12 | from media_utility.image_tool import get_rescaled_image, get_thumbnail, get_scene_image, open_img 13 | from PIL import Image 14 | 15 | # from copy import copy 16 | from pathlib import Path 17 | 18 | YKLLayoutEditUpdate, EVT_LAYOUT_EDIT_UPDATE = NE.NewCommandEvent() 19 | YKLLayoutPreviewUpdate, EVT_LAYOUT_PREVIEW_UPDATE = NE.NewCommandEvent() 20 | 21 | 22 | class YKLLauoutEditDialog(wx.Dialog): 23 | def __init__(self, parent, ctx, sceneblock): 24 | super().__init__(parent, title="レイアウト編集", style=wx.CAPTION | wx.CLOSE_BOX) 25 | self.ctx = ctx 26 | self.sceneblock = sceneblock 27 | 28 | scene_w, scene_h = self.ctx.resolution 29 | pp_bg_w, pp_bg_h = int(scene_w * (400 / scene_h)), 400 30 | pp_w, pp_h = pp_bg_w + 100, pp_bg_h + 100 31 | self_w, self_h = pp_w + 320, pp_h + 80 + 10 32 | self.SetSize(self_w, self_h) 33 | 34 | whole_vbox = wx.BoxSizer(wx.VERTICAL) 35 | hbox = wx.BoxSizer(wx.HORIZONTAL) 36 | lvbox = wx.BoxSizer(wx.VERTICAL) 37 | pplabel = wx.StaticText(self, wx.ID_ANY, "プレビュー:(キャラ素材をドラッグで移動できます)") 38 | lvbox.Add(pplabel, flag=wx.ALL, border=5) 39 | 40 | self.ppanel = ImageMovePanel( 41 | self, wx.ID_ANY, self.ctx, self.sceneblock, 42 | (pp_w, pp_h), (pp_bg_w, pp_bg_h)) 43 | lvbox.Add(self.ppanel) 44 | hbox.Add(lvbox) 45 | 46 | rvbox = wx.BoxSizer(wx.VERTICAL) 47 | splabel = wx.StaticText(self, wx.ID_ANY, "設定:") 48 | rvbox.Add(splabel, flag=wx.ALL, border=5) 49 | self.slistbook = ImageSettingListBook( 50 | self, wx.ID_ANY, self.ctx, self.sceneblock, size=(310, 350)) 51 | rvbox.Add(self.slistbook, 1, flag=wx.ALL | wx.EXPAND, border=5) 52 | 53 | rvbox.Add(wx.StaticLine(self, wx.ID_ANY), flag=wx.EXPAND) 54 | 55 | bg_set_btn = wx.Button(self, wx.ID_ANY, "背景画像を設定") 56 | self.Bind(wx.EVT_BUTTON, self.bg_open, id=bg_set_btn.GetId()) 57 | rvbox.Add(bg_set_btn, flag=wx.TOP | wx.BOTTOM | wx.ALIGN_CENTER_HORIZONTAL, border=10) 58 | save_img_btn = wx.Button(self, wx.ID_ANY, "レイヤを結合して画像保存") 59 | self.Bind(wx.EVT_BUTTON, self.save_scene_image, id=save_img_btn.GetId()) 60 | rvbox.Add(save_img_btn, flag=wx.ALIGN_CENTER_HORIZONTAL) 61 | img_caution = wx.StaticText(self, wx.ID_ANY, "(背景画像は結合されません)") 62 | rvbox.Add(img_caution, flag=wx.TOP | wx.ALIGN_CENTER_HORIZONTAL, border=5) 63 | hbox.Add(rvbox) 64 | 65 | hbox.Layout() 66 | whole_vbox.Add(hbox) 67 | 68 | btns = wx.BoxSizer(wx.HORIZONTAL) 69 | cancel_btn = wx.Button(self, wx.ID_CANCEL, "キャンセル") 70 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=cancel_btn.GetId()) 71 | btns.Add(cancel_btn, flag=wx.ALL, border=5) 72 | ok_btn = wx.Button(self, wx.ID_OK, "適用して閉じる") 73 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=ok_btn.GetId()) 74 | btns.Add(ok_btn, flag=wx.ALL, border=5) 75 | btns.Layout() 76 | 77 | whole_vbox.Add(btns, 0, flag=wx.RIGHT | wx.TOP | wx.ALIGN_RIGHT, border=5) 78 | 79 | self.SetSizer(whole_vbox) 80 | 81 | self.Bind(EVT_LAYOUT_PREVIEW_UPDATE, self.OnPreviewUpdate) 82 | self.Bind(EVT_LAYOUT_EDIT_UPDATE, self.OnEditUpdate) 83 | 84 | def OnBtnClick(self, event): 85 | self.sceneblock.sozai_order = self.sceneblock.sozai_order 86 | self.sceneblock.sozai_pos = self.sceneblock.sozai_pos 87 | self.sceneblock.sozai_scale = self.sceneblock.sozai_scale 88 | self.EndModal(event.GetId()) 89 | 90 | def OnPreviewUpdate(self, event): 91 | for i in range(self.slistbook.GetPageCount()): 92 | self.slistbook.set_order(i) 93 | self.slistbook.set_position(i) 94 | self.slistbook.SetSelection(event.GetInt()) 95 | 96 | def OnEditUpdate(self, event): 97 | self.ppanel.update_pos_and_scale(event.GetInt()) 98 | 99 | def bg_open(self, _event): 100 | with wx.FileDialog( 101 | self, "背景を指定", str(self.ctx.get_project_path()), "", 102 | wildcard="PNG and JPG files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg", 103 | style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST 104 | ) as fileDialog: 105 | if fileDialog.ShowModal() == wx.ID_CANCEL: 106 | return 107 | pathname = fileDialog.GetPath() 108 | bg_image = wx.Bitmap(pathname) 109 | bg_width, bg_height = bg_image.GetWidth(), bg_image.GetHeight() 110 | if bg_width/bg_height != self.ctx.resolution[0]/self.ctx.resolution[1]: 111 | wx.MessageBox("画像のアスペクト比はプロジェクトの解像度設定と一致している必要があります", 112 | "エラー", wx.ICON_QUESTION | wx.OK, self) 113 | return 114 | self.ppanel.update_background(pathname) 115 | del bg_image 116 | 117 | def save_scene_image(self, _event): 118 | with wx.FileDialog( 119 | self, "画像を保存", "", self.ctx.get_project_name() + "_scene_image", 120 | wildcard="PNG files (*.png)|*.png", style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT 121 | ) as fileDialog: 122 | if fileDialog.ShowModal() == wx.ID_CANCEL: 123 | return 124 | pathname = fileDialog.GetPath() 125 | images = [sozai.get_image() for sozai in self.sceneblock.get_sozais()] 126 | images = [get_rescaled_image(image, scale) 127 | for image, scale in zip(images, self.sceneblock.sozai_scale)] 128 | image = get_scene_image( 129 | images, 130 | self.sceneblock.sozai_pos, 131 | self.sceneblock.sozai_order, 132 | bg_size=self.ctx.resolution, 133 | bg_color=(0,0,0,0)) 134 | image.save(pathname, quality=95) 135 | 136 | 137 | class ImageSettingListBook(wx.Listbook): 138 | def __init__(self, parent, idx, ctx, block, size): 139 | super().__init__(parent, idx, size=size) 140 | self.ctx = ctx 141 | self.sceneblock = block 142 | im_size = 64 143 | il = wx.ImageList(im_size, im_size) 144 | for sozai in self.sceneblock.get_sozais(): 145 | image = sozai.get_image() 146 | image = get_thumbnail(image, size=im_size) 147 | bmp = wx.Bitmap.FromBufferRGBA(im_size, im_size, image.tobytes()) 148 | il.Add(bmp) 149 | self.AssignImageList(il) 150 | for i, sozai in enumerate(self.sceneblock.get_sozais()): 151 | page = ImageSettingPanel(self, wx.ID_ANY, self.ctx, self.sceneblock, i, size=(310, 250)) 152 | self.AddPage(page, sozai.get_name(), imageId=i) 153 | for i in range(self.GetPageCount()): 154 | self.set_order(i) 155 | self.set_position(i) 156 | self.set_scale(i) 157 | 158 | def set_order(self, idx): 159 | order = self.sceneblock.sozai_order 160 | page = self.GetPage(idx) 161 | page.set_order(order[idx]) 162 | 163 | def set_position(self, idx): 164 | pos = self.sceneblock.sozai_pos 165 | page = self.GetPage(idx) 166 | page.set_position(pos[idx]) 167 | 168 | def set_scale(self, idx): 169 | scale = self.sceneblock.sozai_scale 170 | page = self.GetPage(idx) 171 | page.set_scale(scale[idx]) 172 | 173 | 174 | class ImageSettingPanel(wx.Panel): 175 | def __init__(self, parent, idx, ctx, block, page_count, size): 176 | super().__init__(parent, idx, size=size) 177 | self.ctx = ctx 178 | self.sceneblock = block 179 | self.page_count = page_count 180 | vbox = wx.BoxSizer(wx.VERTICAL) 181 | 182 | order_hbox = wx.BoxSizer(wx.HORIZONTAL) 183 | order_lbl = wx.StaticText(self, wx.ID_ANY, "描画順序:") 184 | order_hbox.Add(order_lbl, flag=wx.ALIGN_CENTER_VERTICAL) 185 | self.order_tc = wx.TextCtrl(self, wx.ID_ANY, size=(50, -1)) 186 | self.order_tc.SetEditable(False) 187 | order_hbox.Add(self.order_tc, 1, flag=wx.ALIGN_CENTER_VERTICAL) 188 | vbox.Add(order_hbox, flag=wx.ALL, border=5) 189 | 190 | pos_hbox = wx.BoxSizer(wx.HORIZONTAL) 191 | pos_lbl_x = wx.StaticText(self, wx.ID_ANY, "位置 x:") 192 | pos_hbox.Add(pos_lbl_x, flag=wx.ALIGN_CENTER_VERTICAL) 193 | w, h = 1920, 1080 # 最大画質はフルHD 194 | x_min, x_max = -w, w * 2 195 | self.x_spin = wx.SpinCtrl(self, wx.ID_ANY, min=x_min, max=x_max, size=(60, -1)) 196 | self.Bind(wx.EVT_SPINCTRL, self.on_spin_change, id=self.x_spin.GetId()) 197 | # self.Bind(wx.EVT_TEXT, self.on_spin_change, id=self.x_spin.GetId()) 198 | pos_hbox.Add(self.x_spin, flag=wx.ALIGN_CENTER_VERTICAL) 199 | pos_lbl_y = wx.StaticText(self, wx.ID_ANY, " y:") 200 | pos_hbox.Add(pos_lbl_y, flag=wx.ALIGN_CENTER_VERTICAL) 201 | y_min, y_max = -h, h * 2 202 | self.y_spin = wx.SpinCtrl(self, wx.ID_ANY, min=y_min, max=y_max, size=(60, -1)) 203 | self.Bind(wx.EVT_SPINCTRL, self.on_spin_change, id=self.y_spin.GetId()) 204 | pos_hbox.Add(self.y_spin, flag=wx.ALIGN_CENTER_VERTICAL) 205 | vbox.Add(pos_hbox, flag=wx.ALL, border=5) 206 | 207 | scale_hbox = wx.BoxSizer(wx.HORIZONTAL) 208 | scale_lbl = wx.StaticText(self, wx.ID_ANY, "拡大率:") 209 | scale_hbox.Add(scale_lbl, flag=wx.ALIGN_CENTER_VERTICAL) 210 | self.scale_slider = wx.Slider(self, wx.ID_ANY, minValue=1, maxValue=1000, size=(160, -1)) 211 | self.Bind(wx.EVT_SLIDER, self.on_slider_change, id=self.scale_slider.GetId()) 212 | scale_hbox.Add(self.scale_slider, flag=wx.ALIGN_CENTER_VERTICAL) 213 | self.scale_spin = wx.SpinCtrlDouble(self, wx.ID_ANY, min=0.01, max=10.0, inc=0.01, size=(60, -1)) 214 | self.Bind(wx.EVT_SPINCTRLDOUBLE, self.on_spin_change, id=self.scale_spin.GetId()) 215 | scale_hbox.Add(self.scale_spin, flag=wx.ALIGN_CENTER_VERTICAL) 216 | vbox.Add(scale_hbox, flag=wx.ALL, border=5) 217 | 218 | img_save_btn = wx.Button(self, wx.ID_ANY, "このレイヤを画像保存") 219 | self.Bind(wx.EVT_BUTTON, self.save_scene_image, id=img_save_btn.GetId()) 220 | vbox.Add(img_save_btn, flag=wx.ALL | wx.ALIGN_CENTER_HORIZONTAL, border=5) 221 | 222 | self.SetSizer(vbox) 223 | 224 | def on_spin_change(self, event): 225 | if event.GetId() == self.x_spin.GetId(): 226 | self.sceneblock.sozai_pos[self.page_count] = (self.x_spin.GetValue(), self.sceneblock.sozai_pos[self.page_count][1]) 227 | elif event.GetId() == self.y_spin.GetId(): 228 | self.sceneblock.sozai_pos[self.page_count] = (self.sceneblock.sozai_pos[self.page_count][0], self.y_spin.GetValue()) 229 | elif event.GetId() == self.scale_spin.GetId(): 230 | self.sceneblock.sozai_scale[self.page_count] = self.scale_spin.GetValue() 231 | self.scale_slider.SetValue(int(self.scale_spin.GetValue() * 100)) 232 | new_event = YKLLayoutEditUpdate(self.GetId()) 233 | new_event.SetInt(self.page_count) 234 | wx.PostEvent(self, new_event) 235 | 236 | def on_slider_change(self, event): 237 | self.scale_spin.SetValue(self.scale_slider.GetValue() / 100) 238 | self.sceneblock.sozai_scale[self.page_count] = self.scale_spin.GetValue() 239 | new_event = YKLLayoutEditUpdate(self.GetId()) 240 | new_event.SetInt(self.page_count) 241 | wx.PostEvent(self, new_event) 242 | 243 | def set_order(self, order): 244 | self.order_tc.SetValue(str(order + 1)) 245 | 246 | def set_position(self, pos): 247 | x, y = pos 248 | self.x_spin.SetValue(x) 249 | self.y_spin.SetValue(y) 250 | 251 | def set_scale(self, scale): 252 | self.scale_slider.SetValue(int(scale * 100)) 253 | self.scale_spin.SetValue(scale) 254 | 255 | def save_scene_image(self, _event): 256 | sozai = self.sceneblock.get_sozais()[self.page_count] 257 | with wx.FileDialog( 258 | self, "画像を保存", "", self.ctx.get_project_name() + "_scene_image_" + sozai.get_name(), 259 | wildcard="PNG files (*.png)|*.png", style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT 260 | ) as fileDialog: 261 | if fileDialog.ShowModal() == wx.ID_CANCEL: 262 | return 263 | pathname = fileDialog.GetPath() 264 | image = sozai.get_image() 265 | image = get_rescaled_image(image, self.sceneblock.sozai_scale[self.page_count]) 266 | image = get_scene_image( 267 | [image,], 268 | self.sceneblock.sozai_pos[self.page_count:self.page_count+1], 269 | [0,], 270 | bg_size=self.ctx.resolution, 271 | bg_color=(0,0,0,0)) 272 | image.save(pathname, quality=95) 273 | 274 | 275 | class ImageMovePanel(wx.Panel): 276 | def __init__(self, parent, idx, ctx, sceneblock, size, scene_size): 277 | super().__init__(parent, idx, size=size) 278 | self.ctx = ctx 279 | self.sceneblock = sceneblock 280 | self.objs = [] 281 | self.drag_obj = None 282 | self.scene_size = scene_size 283 | self.bg_pos = (50, 50) 284 | self.resolution = self.ctx.resolution 285 | self.drag_start_pos = wx.Point(0, 0) 286 | self._buffer = wx.Bitmap(*self.GetClientSize()) 287 | self.init_draw_buffer() 288 | self.init_images() 289 | 290 | self.Bind(wx.EVT_PAINT, self.on_paint) 291 | self.Bind(wx.EVT_LEFT_DOWN, self.on_mouse_leftdown) 292 | self.Bind(wx.EVT_LEFT_UP, self.on_mouse_leftup) 293 | self.Bind(wx.EVT_MOTION, self.on_mouse_motion) 294 | 295 | def init_images(self): 296 | if self.sceneblock.bg_path is not None: 297 | if not Path(self.sceneblock.bg_path).exists(): 298 | wx.MessageBox("参考背景画像を見つけられませんでした\n" 299 | "(設定はまだ変更されていません)", "エラー", 300 | wx.ICON_QUESTION | wx.OK, None) 301 | self.sceneblock.bg_path = None 302 | if self.sceneblock.bg_path is None: 303 | bg_bmp = wx.Bitmap.FromRGBA(*self.scene_size, *self.ctx.bg_color) 304 | bg_obj = ImageObj(bg_bmp, *self.bg_pos) 305 | self.objs.append(bg_obj) 306 | else: 307 | img = open_img(str(self.sceneblock.bg_path)) 308 | img = img.resize(self.scene_size, Image.LANCZOS) 309 | # bg_bmp = wx.Bitmap(str(self.sceneblock.bg_path)) 310 | if len(img.getpixel((0,0))) == 3: 311 | bg_bmp = wx.Bitmap.FromBuffer(*img.size, img.tobytes()) 312 | elif len(img.getpixel((0,0))) == 4: 313 | bg_bmp = wx.Bitmap.FromBufferRGBA(*img.size, img.tobytes()) 314 | else: 315 | return 316 | # SetSizeは内部でSetWidthなどを呼んでいるようだが、それらは非推奨である 317 | # bg_bmp.SetSize(size=self.scene_size) 318 | bg_obj = ImageObj(bg_bmp, *self.bg_pos) 319 | self.objs.append(bg_obj) 320 | 321 | self.update_sozai_scale() 322 | 323 | self.update_drawing() 324 | 325 | def update_background(self, pathname): 326 | self.sceneblock.bg_path = pathname 327 | img = open_img(str(self.sceneblock.bg_path)) 328 | img = img.resize(self.scene_size, Image.LANCZOS) 329 | # bg_bmp = wx.Bitmap(str(self.sceneblock.bg_path)) 330 | if len(img.getpixel((0,0))) == 3: 331 | bg_bmp = wx.Bitmap.FromBuffer(*img.size, img.tobytes()) 332 | elif len(img.getpixel((0,0))) == 4: 333 | bg_bmp = wx.Bitmap.FromBufferRGBA(*img.size, img.tobytes()) 334 | else: 335 | return 336 | # SetSizeは内部でSetWidthなどを呼んでいるようだが、それらは非推奨である 337 | # bg_bmp.SetSize(size=self.scene_size) 338 | bg_obj = ImageObj(bg_bmp, *self.bg_pos) 339 | self.objs[0] = bg_obj 340 | 341 | self.update_drawing() 342 | 343 | def update_pos_and_scale(self, idx): 344 | self.update_sozai_scale() 345 | obj = self.objs[self.sceneblock.sozai_order[idx] + 1] 346 | obj.pos.x, obj.pos.y = self.to_virtual_pos(self.sceneblock.sozai_pos[idx]) 347 | self.update_drawing() 348 | 349 | def update_sozai_scale(self): 350 | scale = self.scene_size[0] / self.ctx.resolution[0] 351 | 352 | sozai_images = [get_rescaled_image( 353 | sozai.get_image(), scale * scl 354 | ) for sozai, scl in zip(self.sceneblock.get_sozais(), self.sceneblock.sozai_scale)] 355 | 356 | sozai_bmps = [wx.Bitmap.FromBufferRGBA(*img.size, img.tobytes()) for img in sozai_images] 357 | 358 | sozai_objs = [MoveImageObj( 359 | bmp, 360 | int(pos[0] * scale) + self.bg_pos[0], 361 | int(pos[1] * scale) + self.bg_pos[1] 362 | ) for bmp, pos in zip(sozai_bmps, self.sceneblock.sozai_pos)] 363 | self.objs = self.objs[:1] 364 | self.objs += [None for _ in range(len(sozai_objs))] 365 | for i, order in enumerate(self.sceneblock.sozai_order): 366 | self.objs[i+1] = sozai_objs[order] 367 | # self.update_drawing() 368 | 369 | def init_draw_buffer(self): 370 | size = self.GetClientSize() 371 | self._buffer = wx.Bitmap(*size) 372 | self.update_drawing() 373 | 374 | def update_drawing(self): 375 | dc = wx.MemoryDC() 376 | dc.SelectObject(self._buffer) 377 | 378 | self.draw(dc) 379 | del dc 380 | 381 | self.Refresh() 382 | self.Update() 383 | 384 | def draw(self, dc): 385 | dc.Clear() 386 | for obj in self.objs: 387 | if isinstance(obj, ImageObj): 388 | obj.draw(dc, False) 389 | elif isinstance(obj, MoveImageObj): 390 | obj.draw(dc, True) 391 | 392 | def on_paint(self, _event): 393 | dc = wx.PaintDC(self) 394 | dc.DrawBitmap(self._buffer, 0, 0, True) 395 | 396 | def find_obj(self, point): 397 | result = None 398 | for obj in self.objs: 399 | if isinstance(obj, MoveImageObj): 400 | if obj.hit_test(point): 401 | result = obj 402 | return result 403 | 404 | def on_mouse_leftdown(self, event): 405 | pos = event.GetPosition() 406 | obj = self.find_obj(pos) 407 | if obj: 408 | self.drag_obj = obj 409 | self.drag_start_pos = pos 410 | self.drag_obj.save_pos_diff(pos) 411 | idx = self.sceneblock.sozai_order.index(self.objs.index(obj)-1) 412 | pos = tuple(self.to_physical_pos(self.drag_obj.pos)) 413 | self.sceneblock.sozai_pos[idx] = pos # 割と危険な操作。sceneblock内でis_savedがFalseにならない 414 | # ダイアログを閉じる前に、自分自身をセットし直すことで強制的に発火させることは可能 415 | event = YKLLayoutPreviewUpdate(self.GetId()) 416 | event.SetInt(idx) 417 | self.update_order(idx, obj) 418 | wx.PostEvent(self, event) 419 | 420 | def update_order(self, idx, obj): 421 | self.objs.remove(obj) 422 | self.objs.append(obj) 423 | self.update_drawing() 424 | sozai_num = len(self.sceneblock.get_sozais()) 425 | cur_order = self.sceneblock.sozai_order[idx] 426 | for i in range(cur_order+1, sozai_num): 427 | j = self.sceneblock.sozai_order.index(i) 428 | self.sceneblock.sozai_order[j] -= 1 429 | self.sceneblock.sozai_order[idx] = sozai_num - 1 430 | 431 | def on_mouse_leftup(self, event): 432 | if self.drag_obj: 433 | pos = event.GetPosition() 434 | self.drag_obj.pos.x = pos.x + self.drag_obj.diff_pos.x 435 | self.drag_obj.pos.y = pos.y + self.drag_obj.diff_pos.y 436 | idx = self.sceneblock.sozai_order.index(self.objs.index(self.drag_obj)-1) 437 | pos = tuple(self.to_physical_pos(self.drag_obj.pos)) 438 | self.sceneblock.sozai_pos[idx] = pos 439 | event = YKLLayoutPreviewUpdate(self.GetId()) 440 | event.SetInt(idx) 441 | wx.PostEvent(self, event) 442 | self.drag_obj = None 443 | self.update_drawing() 444 | 445 | def on_mouse_motion(self, event): 446 | if self.drag_obj is None: 447 | return 448 | pos = event.GetPosition() 449 | self.drag_obj.pos.x = pos.x + self.drag_obj.diff_pos.x 450 | self.drag_obj.pos.y = pos.y + self.drag_obj.diff_pos.y 451 | idx = self.sceneblock.sozai_order.index(self.objs.index(self.drag_obj)-1) 452 | pos = tuple(self.to_physical_pos(self.drag_obj.pos)) 453 | self.sceneblock.sozai_pos[idx] = pos 454 | event = YKLLayoutPreviewUpdate(self.GetId()) 455 | event.SetInt(idx) 456 | wx.PostEvent(self, event) 457 | self.update_drawing() 458 | 459 | def to_physical_pos(self, pos): 460 | return [int((pos.x - self.bg_pos[0]) * self.resolution[0] / self.scene_size[0]), 461 | int((pos.y - self.bg_pos[1]) * self.resolution[1] / self.scene_size[1])] 462 | 463 | def to_virtual_pos(self, pos): 464 | return (int(pos[0] * self.scene_size[0] / self.resolution[0]) + self.bg_pos[0], 465 | int(pos[1] * self.scene_size[1] / self.resolution[1]) + self.bg_pos[1]) 466 | 467 | 468 | class ImageObj: 469 | def __init__(self, bmp, x=0, y=0): 470 | self.bmp = bmp 471 | self.pos = wx.Point(x, y) 472 | self.diff_pos = wx.Point(0, 0) 473 | 474 | def get_rect(self): 475 | return wx.Rect(self.pos.x, self.pos.y, self.bmp.GetWidth(), self.bmp.GetHeight()) 476 | 477 | def draw(self, dc, select_enable): 478 | if self.bmp.IsOk(): 479 | r = self.get_rect() 480 | 481 | dc.SetPen(wx.Pen(wx.BLACK, 4)) 482 | dc.DrawBitmap(self.bmp, r.GetX(), r.GetY(), True) 483 | 484 | if select_enable: 485 | dc.SetBrush(wx.TRANSPARENT_BRUSH) 486 | dc.SetPen(wx.Pen(wx.RED, 2)) 487 | dc.DrawRectangle(r.GetX(), r.GetY(), r.GetWidth(), r.GetHeight()) 488 | return True 489 | else: 490 | return False 491 | 492 | def hit_test(self, _point): 493 | pass 494 | 495 | def save_pos_diff(self, _point): 496 | pass 497 | 498 | 499 | class MoveImageObj(ImageObj): 500 | def __init__(self, bmp, x=0, y=0): 501 | super().__init__(bmp, x, y) 502 | 503 | def hit_test(self, point): 504 | rect = self.get_rect() 505 | return rect.Contains(point) 506 | 507 | def save_pos_diff(self, point): 508 | self.diff_pos.x = self.pos.x - point.x 509 | self.diff_pos.y = self.pos.y - point.y 510 | -------------------------------------------------------------------------------- /ykl_ui/scene_edit_dialog.py: -------------------------------------------------------------------------------- 1 | """ 2 | YukkuLips 3 | 4 | Copyright (c) 2018 - 2020 SuitCase 5 | 6 | This software is released under the MIT License. 7 | https://github.com/PickledChair/YukkuLips/blob/master/LICENSE.txt 8 | """ 9 | 10 | import shutil 11 | from pathlib import Path 12 | import subprocess 13 | 14 | import wx 15 | import wx.adv as ADV 16 | import wx.lib.newevent as NE 17 | from media_utility.image_tool import get_thumbnail 18 | from media_utility.audio_tool import convert_mp3_to_wav 19 | from media_utility.video_tool import AnimeType, LineShape, AnimeSetting 20 | from ykl_core.chara_sozai import CharaSozai 21 | 22 | YKLSceneEditUpdate, EVT_SCENE_EDIT_UPDATE = NE.NewCommandEvent() 23 | 24 | 25 | class YKLSceneEditDialog(wx.Dialog): 26 | def __init__(self, parent, ctx, sceneblock): 27 | super().__init__(parent, title="シーンブロック編集", size=(700, 510)) 28 | self.ctx = ctx 29 | self.temp_dir = self.ctx.get_project_path() / "temp" 30 | if not self.temp_dir.exists(): 31 | self.temp_dir.mkdir() 32 | else: 33 | shutil.rmtree(self.temp_dir) 34 | self.temp_dir.mkdir() 35 | self.sceneblock = sceneblock 36 | # シーンブロックのフォルダがないと、一時的な音声ファイルを保存できない 37 | if not self.sceneblock.is_saved(): 38 | self.sceneblock.save() 39 | self.sceneblock.movie_generated = False 40 | vbox = wx.BoxSizer(wx.VERTICAL) 41 | self.selistbook = SceneEditListBook(self, wx.ID_ANY, self.ctx, self.sceneblock, size=(680, 380)) 42 | vbox.Add(self.selistbook, flag=wx.ALL, border=10) 43 | 44 | movie_box = wx.BoxSizer(wx.HORIZONTAL) 45 | len_lbl = wx.StaticText(self, wx.ID_ANY, "動画の長さ:") 46 | movie_box.Add(len_lbl, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=5) 47 | self.len_spin = wx.SpinCtrlDouble(self, wx.ID_ANY, min=1.0, max=120.0, inc=0.01, size=(60, -1)) 48 | sound_time = self.sceneblock.get_movie_time() 49 | if sound_time > 0: 50 | self.len_spin.SetValue(sound_time) 51 | self.len_spin.Enable(False) 52 | else: 53 | self.len_spin.SetValue(self.sceneblock.movie_time) 54 | movie_box.Add(self.len_spin, flag=wx.TOP | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=5) 55 | intvl_lbl = wx.StaticText(self, wx.ID_ANY, "秒 末尾の追加時間:") 56 | movie_box.Add(intvl_lbl, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=5) 57 | self.intvl_spin = wx.SpinCtrlDouble(self, wx.ID_ANY, min=0.1, max=120.0, inc=0.01, size=(60, -1)) 58 | self.intvl_spin.SetValue(self.sceneblock.suffix_time) 59 | movie_box.Add(self.intvl_spin, flag=wx.TOP | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=5) 60 | intvl_lbl2 = wx.StaticText(self, wx.ID_ANY, "秒") 61 | movie_box.Add(intvl_lbl2, flag=wx.ALL | wx.ALIGN_CENTER_VERTICAL, border=5) 62 | vbox.Add(movie_box, flag=wx.ALL, border=5) 63 | 64 | # if self.selistbook.has_anime: 65 | # self.len_spin.Enable(False) 66 | # self.intvl_spin.Enable(False) 67 | 68 | btns = wx.BoxSizer(wx.HORIZONTAL) 69 | self.gen_movie_btn = wx.Button(self, wx.ID_ANY, "動画を出力") 70 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=self.gen_movie_btn.GetId()) 71 | btns.Add(self.gen_movie_btn, flag=wx.ALL, border=5) 72 | cancel_btn = wx.Button(self, wx.ID_CANCEL, "キャンセル") 73 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=cancel_btn.GetId()) 74 | btns.Add(cancel_btn, flag=wx.ALL, border=5) 75 | ok_btn = wx.Button(self, wx.ID_OK, "適用して閉じる") 76 | self.Bind(wx.EVT_BUTTON, self.OnBtnClick, id=ok_btn.GetId()) 77 | btns.Add(ok_btn, flag=wx.ALL, border=5) 78 | btns.Layout() 79 | vbox.Add(btns, 0, flag=wx.RIGHT | wx.TOP | wx.ALIGN_RIGHT, border=5) 80 | self.SetSizer(vbox) 81 | 82 | self.Bind(EVT_SCENE_EDIT_UPDATE, self.OnUpdate) 83 | 84 | def OnUpdate(self, event): 85 | self.set_data() 86 | sound_time = self.sceneblock.get_movie_time() 87 | if sound_time > 0: 88 | self.len_spin.SetValue(sound_time) 89 | self.len_spin.Enable(False) 90 | else: 91 | self.len_spin.SetValue(self.sceneblock.movie_time) 92 | self.len_spin.Enable(True) 93 | 94 | def set_data(self): 95 | self.sceneblock.movie_time = self.len_spin.GetValue() 96 | self.sceneblock.suffix_time = self.intvl_spin.GetValue() 97 | self.selistbook.set_data() 98 | 99 | def generate_movie(self): 100 | self.set_data() 101 | movie_path = self.sceneblock.generate_movie() 102 | p = subprocess.Popen(["open", "-a", "QuickTime Player", str(movie_path)]) 103 | p.wait() 104 | 105 | def OnBtnClick(self, event): 106 | if event.GetId() == self.gen_movie_btn.GetId(): 107 | self.generate_movie() 108 | else: 109 | if not self.sceneblock.movie_generated: 110 | self.set_data() 111 | # self.sceneblock.sozais_unsaved_check() 112 | shutil.rmtree(self.temp_dir) 113 | self.EndModal(event.GetId()) 114 | 115 | 116 | class SceneEditListBook(wx.Listbook): 117 | def __init__(self, parent, idx, ctx, block, size): 118 | super().__init__(parent, idx, size=size) 119 | self.has_anime = False 120 | self.ctx = ctx 121 | self.sceneblock = block 122 | im_size = 64 123 | il = wx.ImageList(im_size, im_size) 124 | for sozai in self.sceneblock.get_sozais(): 125 | image = sozai.get_image() 126 | image = get_thumbnail(image, size=im_size) 127 | bmp = wx.Bitmap.FromBufferRGBA(im_size, im_size, image.tobytes()) 128 | il.Add(bmp) 129 | self.AssignImageList(il) 130 | for i, sozai in enumerate(self.sceneblock.get_sozais()): 131 | page = SceneEditPanel(self, wx.ID_ANY, self.ctx, self.sceneblock, i, size=(680, 370)) 132 | self.AddPage(page, sozai.get_name(), imageId=i) 133 | 134 | self.has_anime = all([self.GetPage(i).has_anime for i in range(self.GetPageCount())]) 135 | 136 | def set_data(self): 137 | for i in range(self.GetPageCount()): 138 | page = self.GetPage(i) 139 | page.set_data() 140 | 141 | 142 | class SceneEditPanel(wx.Panel): 143 | def __init__(self, parent, idx, ctx, block, page_count, size): 144 | super().__init__(parent, idx, size=size) 145 | self.has_anime = False 146 | self.ctx = ctx 147 | self.sceneblock = block 148 | self.page_count = page_count 149 | self.sozai = self.sceneblock.get_sozais()[self.page_count] 150 | 151 | vbox = wx.BoxSizer(wx.VERTICAL) 152 | 153 | self.notebook = wx.Notebook(self, wx.ID_ANY) 154 | self.audio_set_panel = AudioSettingPanel(self.notebook, wx.ID_ANY, self.sozai, self.ctx) 155 | self.notebook.AddPage(self.audio_set_panel, "セリフ・音源設定") 156 | self.anime_set_panel = AnimeSettingPanel(self.notebook, wx.ID_ANY, self.sozai) 157 | self.notebook.AddPage(self.anime_set_panel, "アニメーション設定") 158 | 159 | vbox.Add(self.notebook, flag=wx.EXPAND) 160 | 161 | self.has_anime = self.anime_set_panel.has_anime 162 | 163 | self.SetSizer(vbox) 164 | 165 | def set_data(self): 166 | self.audio_set_panel.set_data() 167 | self.anime_set_panel.set_data() 168 | 169 | 170 | class AudioSettingPanel(wx.Panel): 171 | def __init__(self, parent, idx, sozai, ctx): 172 | super().__init__(parent, idx) 173 | self.sozai = sozai 174 | self.sound = None 175 | self.sf_path_dict = dict() 176 | self.ctx = ctx 177 | self.sound_temp_dir = self.ctx.get_project_path() / "temp" / "sound" 178 | if not self.sound_temp_dir.exists(): 179 | self.sound_temp_dir.mkdir() 180 | sizer = wx.GridBagSizer(0, 0) 181 | scenario_lbl = wx.StaticText(self, wx.ID_ANY, "セリフ:") 182 | sizer.Add(scenario_lbl, (0, 0), flag=wx.ALL, border=10) 183 | self.sceneario_txt = wx.TextCtrl(self, wx.ID_ANY) 184 | self.sceneario_txt.SetValue(self.sozai.speech_content) 185 | sizer.Add(self.sceneario_txt, (0, 1), (3, 6), flag=wx.TOP | wx.BOTTOM | wx.EXPAND, border=10) 186 | 187 | audio_lbl = wx.StaticText(self, wx.ID_ANY, "音源設定:") 188 | sizer.Add(audio_lbl, (3, 0), flag=wx.ALL, border=10) 189 | 190 | change_flag = False 191 | 192 | anime_audio_lbl = wx.StaticText(self, wx.ID_ANY, "アニメ生成用音源:") 193 | sizer.Add(anime_audio_lbl, (4, 1), flag=wx.RIGHT | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 194 | if not Path(self.sozai.anime_audio_path).exists(): 195 | self.sozai.anime_audio_path = "" 196 | change_flag = True 197 | self.anime_audio_pick = wx.FilePickerCtrl(self, wx.ID_ANY, wildcard="WAV files (.wav)|.wav|MP3 files (.mp3)|.mp3", style=wx.FLP_OPEN | wx.FLP_USE_TEXTCTRL | wx.FLP_FILE_MUST_EXIST, path=self.sozai.anime_audio_path) 198 | anime_dt = PathDropTarget(self.anime_audio_pick) 199 | self.anime_audio_pick.SetDropTarget(anime_dt) 200 | self.Bind(wx.EVT_FILEPICKER_CHANGED, self.on_audio_fill, id=self.anime_audio_pick.GetId()) 201 | sizer.Add(self.anime_audio_pick, (4, 2), (1, 3), flag=wx.RIGHT | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 202 | self.anime_audio_unsel = wx.Button(self, wx.ID_ANY, "ファイル指定を解除") 203 | sizer.Add(self.anime_audio_unsel, (4, 5), flag=wx.RIGHT | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 204 | self.Bind(wx.EVT_BUTTON, self.on_unsel_btn, id=self.anime_audio_unsel.GetId()) 205 | self.anime_audio_play = wx.Button(self, wx.ID_ANY, "再生") 206 | sizer.Add(self.anime_audio_play, (4, 6), flag=wx.RIGHT | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 207 | self.Bind(wx.EVT_BUTTON, self.on_play_sound, id=self.anime_audio_play.GetId()) 208 | 209 | movie_audio_lbl = wx.StaticText(self, wx.ID_ANY, "動画用音源:") 210 | sizer.Add(movie_audio_lbl, (5, 1), flag=wx.RIGHT | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 211 | if not Path(self.sozai.movie_audio_path).exists(): 212 | self.sozai.movie_audio_path = "" 213 | change_flag = True 214 | self.movie_audio_pick = wx.FilePickerCtrl(self, wx.ID_ANY, wildcard="WAV files (.wav)|.wav|MP3 files (.mp3)|.mp3", style=wx.FLP_OPEN | wx.FLP_USE_TEXTCTRL | wx.FLP_FILE_MUST_EXIST, path=self.sozai.movie_audio_path) 215 | movie_dt = PathDropTarget(self.movie_audio_pick) 216 | self.movie_audio_pick.SetDropTarget(movie_dt) 217 | self.Bind(wx.EVT_FILEPICKER_CHANGED, self.on_audio_fill, id=self.movie_audio_pick.GetId()) 218 | sizer.Add(self.movie_audio_pick, (5, 2), (1, 3), flag=wx.RIGHT | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 219 | self.movie_audio_unsel = wx.Button(self, wx.ID_ANY, "ファイル指定を解除") 220 | self.Bind(wx.EVT_BUTTON, self.on_unsel_btn, id=self.movie_audio_unsel.GetId()) 221 | sizer.Add(self.movie_audio_unsel, (5, 5), flag=wx.RIGHT | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 222 | self.movie_audio_play = wx.Button(self, wx.ID_ANY, "再生") 223 | sizer.Add(self.movie_audio_play, (5, 6), flag=wx.RIGHT | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 224 | self.Bind(wx.EVT_BUTTON, self.on_play_sound, id=self.movie_audio_play.GetId()) 225 | prestart_lbl = wx.StaticText(self, wx.ID_ANY, "再生までの時間:") 226 | sizer.Add(prestart_lbl, (6, 1), flag=wx.RIGHT | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 227 | self.prestart_spin = wx.SpinCtrlDouble(self, wx.ID_ANY, min=0., max=120., inc=0.01, size=(60, -1)) 228 | self.prestart_spin.SetValue(self.sozai.prestart_time) 229 | sizer.Add(self.prestart_spin, (6, 2), flag=wx.RIGHT | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 230 | prestart_lbl2 = wx.StaticText(self, wx.ID_ANY, "秒") 231 | sizer.Add(prestart_lbl2, (6, 3), flag=wx.RIGHT | wx.BOTTOM | wx.ALIGN_CENTER_VERTICAL, border=10) 232 | 233 | self.SetSizer(sizer) 234 | sizer.Fit(self) 235 | 236 | if change_flag: 237 | wx.MessageBox("一部の音声ファイルが見つかりませんでした。\n" 238 | "ファイルパスを指定し直す必要があります。", "確認", 239 | wx.ICON_EXCLAMATION | wx.OK) 240 | 241 | def set_data(self): 242 | self.sozai.speech_content = self.sceneario_txt.GetValue() 243 | anime_audio_path = self.anime_audio_pick.GetPath() 244 | if Path(anime_audio_path).exists(): 245 | self.sozai.anime_audio_path = anime_audio_path 246 | movie_audio_path = self.movie_audio_pick.GetPath() 247 | if Path(movie_audio_path).exists(): 248 | self.sozai.movie_audio_path = movie_audio_path 249 | self.sozai.prestart_time = self.prestart_spin.GetValue() 250 | 251 | def on_play_sound(self, event): 252 | if event.GetId() == self.anime_audio_play.GetId(): 253 | pick = self.anime_audio_pick 254 | else: 255 | pick = self.movie_audio_pick 256 | 257 | path = pick.GetPath() 258 | if self.sf_path_dict.get(path) is None: 259 | if Path(path).suffix.lower() == ".mp3": 260 | convert_mp3_to_wav(Path(path), str(self.ctx.ffmpeg_path), self.sound_temp_dir) 261 | new_path = Path(str(self.sound_temp_dir / Path(path).stem) + ".wav") 262 | while not new_path.exists(): 263 | pass 264 | self.sf_path_dict[path] = str(new_path) 265 | elif Path(path).suffix.lower() == ".wav": 266 | self.sf_path_dict[path] = path 267 | else: 268 | wx.LogError(f"サポートされていない音声ファイル形式です:{path}") 269 | return 270 | self.sound = ADV.Sound(self.sf_path_dict[path]) 271 | if self.sound.IsOk(): 272 | self.sound.Play() 273 | 274 | def on_audio_fill(self, event): 275 | if event.GetId() == self.anime_audio_pick.GetId(): 276 | if self.movie_audio_pick.GetPath() == "": 277 | self.movie_audio_pick.SetPath(self.anime_audio_pick.GetPath()) 278 | wx.PostEvent(self, YKLSceneEditUpdate(self.GetId())) 279 | 280 | def on_unsel_btn(self, event): 281 | if event.GetId() == self.movie_audio_unsel.GetId(): 282 | self.movie_audio_pick.SetPath("") 283 | else: 284 | self.anime_audio_pick.SetPath("") 285 | wx.PostEvent(self, YKLSceneEditUpdate(self.GetId())) 286 | 287 | 288 | class PathDropTarget(wx.FileDropTarget): 289 | def __init__(self, window): 290 | super().__init__() 291 | self.window = window 292 | 293 | def OnDropFiles(self, x, y, filenames): 294 | if filenames[0][-4:].lower() in [".mp3", ".wav"]: 295 | self.window.SetPath(filenames[0]) 296 | return True 297 | 298 | 299 | class AnimeSettingPanel(wx.ScrolledWindow): 300 | def __init__(self, parent, idx, sozai): 301 | super().__init__(parent, idx) 302 | self.sozai = sozai 303 | # if self.sozai.get_current_image_dic().get('全'): 304 | # self.orderd_parts = ["全",] 305 | # self.anime_images_dict = {0: None} 306 | # self.count_dict = {"全": 1,} 307 | # else: 308 | self.orderd_parts, self.anime_images_dict = self.sozai.get_anime_image_dict() 309 | self.count_dict = CharaSozai.get_count_of_each_part_images_dict(self.orderd_parts, self.anime_images_dict) 310 | # print(self.orderd_parts, self.anime_images_dict, self.count_dict) 311 | vbox = wx.BoxSizer(wx.VERTICAL) 312 | self.widgets_dict = dict() 313 | if '全' in self.orderd_parts: 314 | nothing_lbl = wx.StaticText(self, wx.ID_ANY, "アニメーションなし(「全」パーツを選択中)") 315 | vbox.Add(nothing_lbl) 316 | else: 317 | self.anime_labels = AnimeType.get_labels() 318 | self.anime_type_dict = {k: v for k, v in zip(self.anime_labels, AnimeType)} 319 | settings = self.sozai.create_anime_setting().into_json() 320 | for part in self.count_dict: 321 | if self.count_dict[part] > 1: 322 | setting = settings[part] 323 | self.widgets_dict[part] = self.create_part_anime_widgets_dict( 324 | part, 325 | anime_type=[anime_type.name for anime_type in AnimeType].index(setting["Anime Type"]), 326 | start=setting["Start Time"], 327 | interval=setting["Interval"], 328 | take_time=setting["Take Time"], 329 | frame=setting["Hold Frame"], 330 | threshold=setting["Threshold"], 331 | line=[line_shape.name for line_shape in LineShape].index(setting["Line Shape"]) 332 | ) 333 | vbox.Add(self.widgets_dict[part]["bag"], flag=wx.LEFT, border=20) 334 | vbox.Layout() 335 | if self.widgets_dict: 336 | self.has_anime = True 337 | else: 338 | self.has_anime = False 339 | self.SetSizer(vbox) 340 | self.SetScrollRate(20, 20) 341 | 342 | def create_part_anime_widgets_dict(self, part, anime_type=2, start=0.0, interval=5.0, take_time=0.5, frame=2, threshold=1.0, line=1): 343 | ret_dict = dict() 344 | bag = wx.GridBagSizer(0, 0) 345 | if part in ["前側", "後側"]: 346 | part_name = "後・" + part 347 | else: 348 | part_name = part 349 | part_lbl = wx.StaticText(self, wx.ID_ANY, f"{part_name}:") 350 | bag.Add(part_lbl, (0, 0), flag=wx.TOP | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=10) 351 | type_lbl = wx.StaticText(self, wx.ID_ANY, "アニメタイプ") 352 | bag.Add(type_lbl, (0, 1), flag=wx.TOP | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=10) 353 | # type_choice 354 | ret_dict["type_choice"] = wx.Choice(self, wx.ID_ANY, choices=self.anime_labels) 355 | ret_dict["type_choice"].SetSelection(anime_type) 356 | bag.Add(ret_dict["type_choice"], (0, 2), flag=wx.TOP | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=10) 357 | start_lbl = wx.StaticText(self, wx.ID_ANY, "開始") 358 | bag.Add(start_lbl, (1, 1), flag=wx.TOP | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=10) 359 | # start_spin 360 | ret_dict["start_spin"] = wx.SpinCtrlDouble(self, wx.ID_ANY, min=0., max=120., inc=0.01, size=(60, -1)) 361 | ret_dict["start_spin"].SetValue(start) 362 | start_box = wx.BoxSizer(wx.HORIZONTAL) 363 | start_box.Add(ret_dict["start_spin"], flag=wx.TOP | wx.ALIGN_CENTER_VERTICAL, border=10) 364 | start_lbl2 = wx.StaticText(self, wx.ID_ANY, "秒") 365 | start_box.Add(start_lbl2, flag=wx.TOP | wx.ALIGN_CENTER_VERTICAL, border=10) 366 | bag.Add(start_box, (1, 2), flag=wx.ALIGN_CENTER_VERTICAL) 367 | intvl_lbl = wx.StaticText(self, wx.ID_ANY, "間隔") 368 | bag.Add(intvl_lbl, (2, 1), flag=wx.TOP | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=10) 369 | # interval_spin 370 | ret_dict["interval_spin"] = wx.SpinCtrlDouble(self, wx.ID_ANY, min=0., max=120., inc=0.01, size=(60, -1)) 371 | ret_dict["interval_spin"].SetValue(interval) 372 | intvl_box = wx.BoxSizer(wx.HORIZONTAL) 373 | intvl_box.Add(ret_dict["interval_spin"], flag=wx.TOP | wx.ALIGN_CENTER_VERTICAL, border=10) 374 | intvl_lbl2 = wx.StaticText(self, wx.ID_ANY, "秒") 375 | intvl_box.Add(intvl_lbl2, flag=wx.TOP | wx.ALIGN_CENTER_VERTICAL, border=10) 376 | bag.Add(intvl_box, (2, 2), flag=wx.ALIGN_CENTER_VERTICAL) 377 | time_lbl = wx.StaticText(self, wx.ID_ANY, "時間") 378 | bag.Add(time_lbl, (3, 1), flag=wx.TOP | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=10) 379 | # time_spin 380 | ret_dict["time_spin"] = wx.SpinCtrlDouble(self, wx.ID_ANY, min=0., max=120., inc=0.01, size=(60, -1)) 381 | ret_dict["time_spin"].SetValue(take_time) 382 | time_box = wx.BoxSizer(wx.HORIZONTAL) 383 | time_box.Add(ret_dict["time_spin"], flag=wx.TOP | wx.ALIGN_CENTER_VERTICAL, border=10) 384 | time_lbl2 = wx.StaticText(self, wx.ID_ANY, "秒") 385 | time_box.Add(time_lbl2, flag=wx.TOP | wx.ALIGN_CENTER_VERTICAL, border=10) 386 | bag.Add(time_box, (3, 2), flag=wx.ALIGN_CENTER_VERTICAL) 387 | hold_lbl = wx.StaticText(self, wx.ID_ANY, "中間割合") 388 | bag.Add(hold_lbl, (4, 1), flag=wx.TOP | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=10) 389 | # hold_spin 390 | ret_dict["hold_spin"] = wx.SpinCtrl(self, wx.ID_ANY, min=1, max=60, size=(60, -1)) 391 | ret_dict["hold_spin"].SetValue(frame) 392 | hold_box = wx.BoxSizer(wx.HORIZONTAL) 393 | hold_box.Add(ret_dict["hold_spin"], flag=wx.TOP | wx.ALIGN_CENTER_VERTICAL, border=10) 394 | hold_lbl2 = wx.StaticText(self, wx.ID_ANY, "フレーム") 395 | hold_box.Add(hold_lbl2, flag=wx.TOP | wx.ALIGN_CENTER_VERTICAL, border=10) 396 | bag.Add(hold_box, (4, 2), flag=wx.ALIGN_CENTER_VERTICAL) 397 | threshold_lbl = wx.StaticText(self, wx.ID_ANY, "音量ボーダーの係数") 398 | bag.Add(threshold_lbl, (5, 1), flag=wx.TOP | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=10) 399 | # threshold_spin 400 | ret_dict["threshold_spin"] = wx.SpinCtrlDouble(self, wx.ID_ANY, min=0.1, max=2.0, inc=0.01, size=(60, -1)) 401 | ret_dict["threshold_spin"].SetValue(threshold) 402 | bag.Add(ret_dict["threshold_spin"], (5, 2), flag=wx.TOP | wx.ALIGN_CENTER_VERTICAL, border=10) 403 | line_lbl = wx.StaticText(self, wx.ID_ANY, "音量ボーダーの形状") 404 | bag.Add(line_lbl, (6, 1), flag=wx.TOP | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, border=10) 405 | # line_choice 406 | ret_dict["line_choice"] = wx.Choice(self, wx.ID_ANY, choices=["一次直線", "二次曲線"]) 407 | ret_dict["line_choice"].SetSelection(line) 408 | bag.Add(ret_dict["line_choice"], (6, 2), flag=wx.TOP | wx.ALIGN_CENTER_VERTICAL, border=10) 409 | ret_dict["bag"] = bag 410 | 411 | return ret_dict 412 | 413 | def set_data(self): 414 | anime_setting = AnimeSetting(self.count_dict) 415 | # 全パーツ選択時はself.widgets_dictが空なので、以下のforループは回らない 416 | for part, widgets in self.widgets_dict.items(): 417 | part_set = anime_setting.part_settings[part] 418 | type_choice = widgets["type_choice"] 419 | type_idx = type_choice.GetSelection() 420 | anime_type = AnimeType.from_str(type_choice.GetString(type_idx)) 421 | part_set.anime_type = anime_type 422 | part_set.start = widgets["start_spin"].GetValue() 423 | part_set.interval = widgets["interval_spin"].GetValue() 424 | part_set.take_time = widgets["time_spin"].GetValue() 425 | part_set.stop_frames = widgets["hold_spin"].GetValue() 426 | part_set.sound_threshold = widgets["threshold_spin"].GetValue() 427 | line_choice = widgets["line_choice"] 428 | line_idx = line_choice.GetSelection() 429 | line_shape = LineShape.from_str(line_choice.GetString(line_idx)) 430 | part_set.line_shape = line_shape 431 | self.sozai.set_anime_setting(anime_setting) 432 | -------------------------------------------------------------------------------- /ykl_core/chara_sozai.py: -------------------------------------------------------------------------------- 1 | """ 2 | yukkulips 3 | 4 | copyright (c) 2018 - 2020 suitcase 5 | 6 | this software is released under the mit license. 7 | https://github.com/pickledchair/yukkulips/blob/master/license.txt 8 | """ 9 | 10 | import json 11 | from pathlib import Path 12 | from copy import deepcopy 13 | import shutil 14 | 15 | from media_utility.image_tool import combine_images, get_mirror_image, ImageInfo 16 | from media_utility.video_tool import AnimeType, LineShape, AnimeSetting, FFMPEG_PATH 17 | from media_utility.audio_tool import gen_movie_sound, convert_mp3_to_wav 18 | 19 | 20 | class CharaSozai: 21 | def __init__(self, cs_uuid, sb_uuid, project, resource_path, parts_path_dic={}): 22 | self.__uuid = cs_uuid 23 | self.__sb_uuid = sb_uuid 24 | self.__project = project 25 | 26 | # キャラ素材画像のデータ 27 | self.__resource_path = resource_path 28 | self.__name = self.__resource_path.name 29 | if parts_path_dic: 30 | self.__parts_dirs, self.__parts_path_dic = [resource_path / part for part in parts_path_dic.keys()], parts_path_dic 31 | else: 32 | self.__parts_dirs, self.__parts_path_dic = CharaSozai.__get_image_paths(self.__resource_path) 33 | self.__current_image_dic = self.__set_default_images() 34 | self.__is_mirror_img = False 35 | self.__image = self.__integrate_images() 36 | 37 | # セリフ 38 | self.__speech_content = "" 39 | self.__anime_audio_path = "" 40 | self.__movie_audio_path = "" 41 | self.__prestart_time = 0.0 42 | 43 | # アニメーション 44 | self.__anime_setting = self.__get_initial_anime_setteing(*self.get_anime_image_dict()) 45 | 46 | self.__content = CharaSozai.__initial_content(self.__name, self.__current_image_dic, self.__anime_setting) 47 | self.__is_saved = False 48 | 49 | @staticmethod 50 | def __get_initial_anime_setteing(orderd_parts, anime_image_dict): 51 | count_dict = CharaSozai.get_count_of_each_part_images_dict(orderd_parts, anime_image_dict) 52 | parts = ['体', '顔', '髪', '眉', '目', '口', '髪(透明)', '他', '前側', '後側'] 53 | anime_types = ["NOTHING", 54 | "NOTHING", 55 | "NOTHING", 56 | "NOTHING", 57 | "CYCLE_ROUND", 58 | "VOLUME_FOLLOW", 59 | "NOTHING", 60 | "NOTHING", 61 | "NOTHING", 62 | "NOTHING", 63 | ] 64 | start_times = [0.0 for _ in range(len(parts))] 65 | intervals = [5.0 for _ in range(len(parts))] 66 | take_times = [0.5 for _ in range(len(parts))] 67 | hold_frames = [2 for _ in range(len(parts))] 68 | thresholds = [1.0 for _ in range(len(parts))] 69 | line_shapes = ["QUADRATIC" for _ in range(len(parts))] 70 | initial_dict = { 71 | part: { 72 | "Anime Type": anime_type, 73 | "Start Time": start_time, 74 | "Interval": interval, 75 | "Take Time": take_time, 76 | "Hold Frame": hold_frame, 77 | "Threshold": threshold, 78 | "Line Shape": line_shape, 79 | } for part, anime_type, start_time, interval, take_time, hold_frame, threshold, line_shape 80 | in zip(parts, anime_types, start_times, intervals, take_times, hold_frames, thresholds, line_shapes) 81 | } 82 | initial_dict = {k: value for k, value in initial_dict.items() if count_dict.get(k)} 83 | return {k: value for k, value in initial_dict.items() if count_dict[k] > 1} 84 | 85 | def create_anime_setting(self): 86 | count_dict = self.get_count_of_each_part_images_dict(*self.get_anime_image_dict()) 87 | anime_setting = AnimeSetting(count_dict) 88 | for part in anime_setting.part_settings.values(): 89 | if self.__anime_setting.get(part.part): 90 | part.anime_type = AnimeType.from_str(self.__anime_setting[part.part]["Anime Type"]) 91 | part.start = self.__anime_setting[part.part]["Start Time"] 92 | part.interval = self.__anime_setting[part.part]["Interval"] 93 | part.take_time = self.__anime_setting[part.part]["Take Time"] 94 | part.stop_frames = self.__anime_setting[part.part]["Hold Frame"] 95 | part.sound_threshold = self.__anime_setting[part.part]["Threshold"] 96 | part.line_shape = LineShape.from_str(self.__anime_setting[part.part]["Line Shape"]) 97 | return anime_setting 98 | 99 | def set_anime_setting(self, anime_setting): 100 | self.__anime_setting = anime_setting.into_json() 101 | self.__content["Anime Setting"] = self.__anime_setting 102 | self.__is_saved = False 103 | 104 | def get_movie_anime_audio(self): 105 | if self.__movie_audio_path == "" or self.__anime_audio_path == "": 106 | return None, None 107 | root = (self.__project.root_path() 108 | / "scene_block" / self.__sb_uuid 109 | / "chara_sozai" / self.__uuid) 110 | sound_dir = root / "sound" 111 | if not sound_dir.exists(): 112 | sound_dir.mkdir() 113 | else: 114 | shutil.rmtree(sound_dir) 115 | sound_dir.mkdir() 116 | if Path(self.__movie_audio_path).suffix.lower() == ".mp3": 117 | movie_audio_path = convert_mp3_to_wav(Path(self.__movie_audio_path), str(FFMPEG_PATH), sound_dir) 118 | else: 119 | movie_audio_path = Path(self.__movie_audio_path) 120 | movie_audio_path = gen_movie_sound(Path(movie_audio_path), str(FFMPEG_PATH), sound_dir, prefix_time=self.__prestart_time) 121 | if Path(self.__anime_audio_path).suffix.lower() == ".mp3": 122 | anime_audio_path = convert_mp3_to_wav(Path(self.__anime_audio_path), str(FFMPEG_PATH), sound_dir) 123 | else: 124 | anime_audio_path = Path(self.__anime_audio_path) 125 | anime_audio_path = gen_movie_sound(Path(anime_audio_path), str(FFMPEG_PATH), sound_dir, prefix_time=self.__prestart_time) 126 | while not (movie_audio_path.exists() and anime_audio_path.exists()): 127 | pass 128 | 129 | return movie_audio_path, anime_audio_path 130 | 131 | @staticmethod 132 | def __into_str_dic(image_dic): 133 | str_dic = {} 134 | for part, paths in image_dic.items(): 135 | str_dic[part] = [str(path) for path in paths] 136 | return str_dic 137 | 138 | @staticmethod 139 | def __from_str_dic(str_dic): 140 | image_dic = {} 141 | for part, paths in str_dic.items(): 142 | image_dic[part] = [Path(path) for path in paths] 143 | return image_dic 144 | 145 | @staticmethod 146 | def __initial_content(name, image_dic, anime_dict): 147 | str_dic = CharaSozai.__into_str_dic(image_dic) 148 | content = { 149 | "Name": name, 150 | "Speech Content": "", 151 | "Anime Audio Path": "", 152 | "Movie Audio Path": "", 153 | "Is Mirror Image": False, 154 | "Image Dict": str_dic, 155 | "Prestart Time": 0.0, 156 | "Anime Setting": anime_dict, 157 | } 158 | return content 159 | 160 | @staticmethod 161 | def open(cs_uuid, sb_uuid, project, resource_path, parts_path_dic={}): 162 | sozai = CharaSozai(cs_uuid, sb_uuid, project, resource_path, parts_path_dic=parts_path_dic) 163 | with (project.root_path() / "scene_block" / sb_uuid / "chara_sozai" / cs_uuid / "charasozai.json").open('r') as f: 164 | content = json.load(f) 165 | sozai.__restore(content) 166 | return sozai 167 | 168 | def __restore(self, content): 169 | self.__name = content.get("Name", "") 170 | self.__speech_content = content.get("Speech Content", "") 171 | self.__anime_audio_path = content.get("Anime Audio Path", "") 172 | self.__movie_audio_path = content.get("Movie Audio Path", "") 173 | self.__prestart_time = content.get("Prestart Time", 0.0) 174 | self.__is_mirror_img = content.get("Is Mirror Image", False) 175 | self.__current_image_dic = CharaSozai.__from_str_dic(content["Image Dict"]) 176 | if self.__is_mirror_img: 177 | self.__image = self.__integrate_images(mirror_flag=self.__is_mirror_img) 178 | else: 179 | self.__image = self.__integrate_images() 180 | self.__anime_setting = content.get("Anime Setting", self.__get_initial_anime_setteing(*self.get_anime_image_dict())) 181 | self.__content = content 182 | self.__is_saved = True 183 | 184 | @staticmethod 185 | def __get_image_paths(base_dir): 186 | part_dirs = list(filter(lambda d: d.is_dir(), base_dir.iterdir())) 187 | images_dic = {} 188 | for part in part_dirs: 189 | part_images = sorted(part.glob("*.png")) 190 | images_dic[part.name] = {} 191 | if part.name == "後": 192 | images_dic[part.name]["前側"] = {} 193 | images_dic[part.name]["後側"] = {} 194 | counted = 0 195 | i = 0 196 | while counted < len(part_images): 197 | m_images = [image for image in part_images if (image.name[:2] == f"{i:02}") and (image.name[2] == 'm')] 198 | u_images = [image for image in part_images if (image.name[:2] == f"{i:02}") and (image.name[2] != 'm')] 199 | if len(m_images) == 0 and len(u_images) == 0: 200 | if len(part_images) == 0: 201 | break 202 | else: 203 | i += 1 204 | continue 205 | else: 206 | if len(m_images) != 0 or len(u_images) != 0: 207 | images_dic[part.name]["前側"][f"{i:02}"] = m_images 208 | images_dic[part.name]["後側"][f"{i:02}"] = u_images 209 | counted += len(m_images) + len(u_images) 210 | i += 1 211 | else: 212 | if part.name == "全": 213 | if part_images: 214 | series = part_images[0].name[:2] 215 | images_dic[part.name] = {} 216 | images_dic[part.name][series] = part_images 217 | continue 218 | counted = 0 219 | i = 0 220 | while counted < len(part_images): 221 | images = [image for image in part_images if image.name[:2] == f"{i:02}"] 222 | if len(images) == 0: 223 | if len(part_images) == 0: 224 | break 225 | else: 226 | i += 1 227 | continue 228 | else: 229 | images_dic[part.name][f"{i:02}"] = images 230 | counted += len(images) 231 | i += 1 232 | return part_dirs, images_dic 233 | 234 | def __set_default_images(self): 235 | images_dic = {} 236 | for part in self.__parts_dirs: 237 | default_images = [] 238 | if len(self.__parts_path_dic[part.name]) > 0: 239 | if part.name == "後": 240 | m_series = list(self.__parts_path_dic[part.name]["前側"].values()) 241 | u_series = list(self.__parts_path_dic[part.name]["後側"].values()) 242 | default_images.append((u_series[0] + m_series[0])[0]) 243 | else: 244 | default_images.append(list(self.__parts_path_dic[part.name].values())[0][0]) 245 | if "00" not in str(default_images[0]) or part.name == '全': 246 | default_images = [] 247 | images_dic[part.name] = default_images 248 | images_dic["髪(透明)"] = [] 249 | if '髪' in [part.name for part in self.__parts_dirs]: 250 | if self.__parts_path_dic['髪'].get("01"): 251 | images_dic["髪(透明)"].append(self.__parts_path_dic['髪']["01"][0]) 252 | # ここでself.__parts_path_dicを編集しているので副作用がある 253 | self.__parts_path_dic['髪'].pop("01") 254 | elif self.__parts_path_dic['髪'].get("00"): 255 | images_dic["髪(透明)"].append(self.__parts_path_dic['髪']["00"][0]) 256 | return images_dic 257 | 258 | def set_part_paths(self, part, series_num, indices, front_indices=[]): 259 | if part == "後": 260 | u_list = [self.__parts_path_dic[part]["後側"][series_num][idx] for idx in indices] 261 | m_list = [self.__parts_path_dic[part]["前側"][series_num][idx] for idx in front_indices] 262 | self.__current_image_dic[part] = u_list + m_list 263 | else: 264 | self.__current_image_dic[part] = [self.__parts_path_dic[part][series_num][idx] for idx in indices] 265 | self.__image = self.__integrate_images(mirror_flag=self.__is_mirror_img) 266 | self.__content["Image Dict"] = CharaSozai.__into_str_dic(self.__current_image_dic) 267 | new_anime_setting = self.__get_initial_anime_setteing(*self.get_anime_image_dict()) 268 | previous_setting = {k: v for k, v in self.__anime_setting.items() if k in list(new_anime_setting.keys())} 269 | new_anime_setting.update(previous_setting) 270 | self.__anime_setting = new_anime_setting 271 | self.__content["Anime Setting"] = self.__anime_setting 272 | self.__is_saved = False 273 | 274 | def __integrate_images(self, base_size=(400, 400), mirror_flag=False): 275 | image_infos = CharaSozai.__sort_images(self.__current_image_dic) 276 | image = combine_images(*image_infos, bg_color=(0, 0, 0, 0)) 277 | if mirror_flag: 278 | image = get_mirror_image(image) 279 | return image 280 | 281 | def is_mirror_img(self): 282 | return self.__is_mirror_img 283 | 284 | def set_mirror_img(self, b): 285 | self.__image = self.__integrate_images(mirror_flag=b) 286 | self.__is_mirror_img = b 287 | self.__content["Is Mirror Image"] = self.__is_mirror_img 288 | self.__is_saved = False 289 | 290 | def get_image(self): 291 | return self.__image 292 | 293 | def get_anime_image_dict(self, image_elements=False): 294 | if self.__current_image_dic.get('全'): 295 | ordered_parts = ['全',] 296 | if image_elements: 297 | anime_image_dict = {0: self.__image,} 298 | else: 299 | anime_image_dict = {0: None,} 300 | return ordered_parts, anime_image_dict 301 | images_order = ('体', '顔', '髪', '眉', '目', '口', '髪(透明)', '他', '前側', '後側') 302 | series_dict = {} 303 | for part in self.__current_image_dic: 304 | if part == "後": 305 | for image_path in self.__current_image_dic["後"]: 306 | if 'm1' in image_path.name: 307 | series_dict['前側'] = image_path.name[:2] 308 | else: 309 | series_dict['後側'] = image_path.name[:2] 310 | else: 311 | for image_path in self.__current_image_dic[part]: 312 | series_dict[part] = image_path.name[:2] 313 | ordered_parts = [name for name in images_order if name in list(series_dict.keys())] 314 | # print(series_dict) 315 | # print(ordered_parts) 316 | front_temp = [] 317 | def image_create(part, series, idx, _dict, create=True): 318 | # print(part, series, idx) 319 | if create and image_elements: 320 | if part == "前側": 321 | self.set_part_paths("後", series, [], front_indices=[idx,]) 322 | front_temp = [idx,] 323 | elif part == "後側": 324 | self.set_part_paths("後", series, [idx,], front_indices=front_temp) 325 | else: 326 | self.set_part_paths(part, series, [idx,]) 327 | current_idx = ordered_parts.index(part) 328 | if current_idx == len(ordered_parts)-1 and image_elements: 329 | _dict[idx] = deepcopy(self.get_image()) 330 | elif current_idx == len(ordered_parts)-1 and (not image_elements): 331 | _dict[idx] = None 332 | else: 333 | next_part = ordered_parts[current_idx+1] 334 | _dict[idx] = dict() 335 | next_dict = _dict[idx] 336 | if next_part in ['前側', '後側']: 337 | part_series = self.__parts_path_dic.get('後') 338 | if part_series: 339 | part_series = part_series.get(next_part) 340 | if part_series: 341 | _part_series = part_series.get(series_dict[next_part]) 342 | if _part_series: 343 | for i in range(len(self.__parts_path_dic['後'][next_part][series_dict[next_part]])): 344 | image_create(next_part, series_dict[next_part], i, next_dict) 345 | else: 346 | image_create(next_part, "", 0, next_dict, create=False) 347 | else: 348 | part_series = self.__parts_path_dic.get(next_part) 349 | if part_series: 350 | _part_series = part_series.get(series_dict[next_part]) 351 | if _part_series: 352 | for i in range(len(self.__parts_path_dic[next_part][series_dict[next_part]])): 353 | image_create(next_part, series_dict[next_part], i, next_dict) 354 | else: 355 | image_create(next_part, "", 0, next_dict, create=False) 356 | anime_image_dict = {} 357 | start_part = ordered_parts[0] 358 | start_series = series_dict[start_part] 359 | start_num = len(self.__parts_path_dic[start_part][start_series]) 360 | for i in range(start_num): 361 | image_create(start_part, start_series, i, anime_image_dict) 362 | return ordered_parts, anime_image_dict 363 | 364 | @staticmethod 365 | def get_count_of_each_part_images_dict(parts, anime_dict): 366 | count_dict = dict() 367 | for part in parts: 368 | count_dict[part] = len(anime_dict) 369 | anime_dict = list(anime_dict.values())[0] 370 | return count_dict 371 | 372 | @staticmethod 373 | def __sort_images(current_image_dic): 374 | image_infos = [] 375 | images_order = ('後', '体', '顔', '髪', '眉', '目', '口', '髪(透明)', '他') 376 | if '全' in list(current_image_dic.keys()): 377 | if len(current_image_dic['全']) != 0: 378 | image_infos.append(ImageInfo(path=current_image_dic['全'][0], 379 | part='全', is_mul=False, is_front=False)) 380 | return image_infos 381 | for part in images_order: 382 | if part in list(current_image_dic.keys()): 383 | if len(current_image_dic[part]) != 0: 384 | if part == '顔': 385 | for path in current_image_dic[part]: 386 | if 'b' in path.name: 387 | image_infos.append(ImageInfo(path=path, 388 | part=part, is_mul=True, is_front=False)) 389 | else: 390 | image_infos.append(ImageInfo(path=path, 391 | part=part, is_mul=False, is_front=False)) 392 | elif part == '後': 393 | for path in current_image_dic[part]: 394 | if 'm1' in path.name: 395 | image_infos.append(ImageInfo(path=path, 396 | part=part, is_mul=False, is_front=True)) 397 | else: 398 | image_infos.append(ImageInfo(path=path, 399 | part=part, is_mul=False, is_front=False)) 400 | else: 401 | for path in current_image_dic[part]: 402 | image_infos.append(ImageInfo(path=path, 403 | part=part, is_mul=False, is_front=False)) 404 | return image_infos 405 | 406 | def get_parts_path_dic(self): 407 | return self.__parts_path_dic 408 | 409 | def get_current_image_dic(self): 410 | return self.__current_image_dic 411 | 412 | def get_name(self): 413 | return self.__name 414 | 415 | def set_name(self, name): 416 | self.__name = name 417 | self.__content["Name"] = self.__name 418 | self.__is_saved = False 419 | 420 | def get_uuid(self): 421 | return self.__uuid 422 | 423 | def set_sb_uuid(self, new_uuid): 424 | self.__sb_uuid = new_uuid 425 | self.__is_saved = False 426 | 427 | @property 428 | def speech_content(self): 429 | return self.__speech_content 430 | 431 | @speech_content.setter 432 | def speech_content(self, speech): 433 | self.__speech_content = speech 434 | self.__content["Speech Content"] = self.__speech_content 435 | self.__is_saved = False 436 | 437 | @property 438 | def anime_audio_path(self): 439 | return self.__anime_audio_path 440 | 441 | @anime_audio_path.setter 442 | def anime_audio_path(self, new_path): 443 | self.__anime_audio_path = str(new_path) 444 | self.__content["Anime Audio Path"] = self.__anime_audio_path 445 | self.__is_saved = False 446 | 447 | @property 448 | def movie_audio_path(self): 449 | return self.__movie_audio_path 450 | 451 | @movie_audio_path.setter 452 | def movie_audio_path(self, new_path): 453 | self.__movie_audio_path = str(new_path) 454 | self.__content["Movie Audio Path"] = self.__movie_audio_path 455 | self.__is_saved = False 456 | 457 | @property 458 | def prestart_time(self): 459 | return self.__prestart_time 460 | 461 | @prestart_time.setter 462 | def prestart_time(self, new_time): 463 | self.__prestart_time = new_time 464 | self.__content["Prestart Time"] = self.__prestart_time 465 | self.__is_saved = False 466 | 467 | def save(self): 468 | root = (self.__project.root_path() 469 | / "scene_block" / self.__sb_uuid 470 | / "chara_sozai" / self.__uuid) 471 | # image_folder = root / "image" 472 | if not root.exists(): 473 | root.mkdir() 474 | # image_folder.mkdir() 475 | else: 476 | if self.__is_saved: 477 | return 478 | sozai_file_path = root / "charasozai.json" 479 | # print(self.__content) 480 | with sozai_file_path.open('w') as f: 481 | json.dump(self.__content, f, indent=4, ensure_ascii=False) 482 | self.__is_saved = True 483 | 484 | def is_saved(self): 485 | return self.__is_saved 486 | --------------------------------------------------------------------------------