From 65c256b6412618677165c272ea03dd08015963e2 Mon Sep 17 00:00:00 2001 From: Brett Date: Sat, 4 Nov 2023 09:52:15 -0500 Subject: [PATCH] New UI Redesign in pyqt6 (#56) * Initial commit for new UI * Initial commit for new UI * WIP * Status bar updates and has an icon for online / offline * Add log_viewer.py * Use JSON for delete_engine_download API * Fix class issue with Downloaders * Move Config class to new ui * Add engine_browser.py * Add a close event handler to the main window * Fix issue with engine manager not deleting engines properly * Rearrange all the files * Add icons and resources * Cache system info in RenderServerProxy * Toolbar polish * Fix resource path in status bar * Add config_dir to misc_helper.py * Add try block to zeroconf setup * Add add_job.py * Add raw args to add_job.py --- config/config.yaml | 1 + dashboard.py | 2 +- main.py | 6 + requirements.txt | 5 +- resources/Rectangle.png | Bin 0 -> 3169 bytes resources/icons/AddProduct.png | Bin 0 -> 1428 bytes resources/icons/Adobe After Effects.png | Bin 0 -> 2005 bytes resources/icons/Blender.png | Bin 0 -> 6277 bytes resources/icons/Console.png | Bin 0 -> 921 bytes resources/icons/Document.png | Bin 0 -> 476 bytes resources/icons/Download.png | Bin 0 -> 979 bytes resources/icons/FFmpeg.png | Bin 0 -> 2288 bytes resources/icons/Gear.png | Bin 0 -> 4831 bytes resources/icons/GreenCircle.png | Bin 0 -> 2272 bytes resources/icons/Monitor.png | Bin 0 -> 249 bytes resources/icons/RedSquare.png | Bin 0 -> 1373 bytes resources/icons/SearchFolder.png | Bin 0 -> 2565 bytes resources/icons/Server.png | Bin 0 -> 694 bytes resources/icons/SoftwareInstaller.png | Bin 0 -> 1459 bytes resources/icons/StopSign.png | Bin 0 -> 1808 bytes resources/icons/Synchronize.png | Bin 0 -> 1433 bytes resources/icons/Trash.png | Bin 0 -> 816 bytes src/api/add_job_helpers.py | 4 +- src/api/api_server.py | 44 +- src/api/server_proxy.py | 36 +- src/engines/blender/blender_downloader.py | 2 +- src/engines/core/base_downloader.py | 4 +- src/engines/engine_manager.py | 21 +- src/engines/ffmpeg/ffmpeg_downloader.py | 2 +- src/init.py | 65 +++ src/ui/__init__.py | 0 src/ui/add_job.py | 289 +++++++++++ src/ui/console.py | 60 +++ src/ui/engine_browser.py | 159 ++++++ src/ui/log_viewer.py | 30 ++ src/ui/main_window.py | 562 +++++++++++++++++++++ src/ui/widgets/__init__.py | 0 src/ui/widgets/dialog.py | 1 + src/ui/widgets/menubar.py | 23 + src/ui/widgets/proportional_image_label.py | 40 ++ src/ui/widgets/statusbar.py | 61 +++ src/ui/widgets/toolbar.py | 49 ++ src/ui/widgets/treeview.py | 29 ++ src/utilities/misc_helper.py | 12 + src/utilities/zeroconf_server.py | 37 +- 45 files changed, 1491 insertions(+), 53 deletions(-) create mode 100644 main.py create mode 100644 resources/Rectangle.png create mode 100644 resources/icons/AddProduct.png create mode 100644 resources/icons/Adobe After Effects.png create mode 100644 resources/icons/Blender.png create mode 100644 resources/icons/Console.png create mode 100644 resources/icons/Document.png create mode 100644 resources/icons/Download.png create mode 100644 resources/icons/FFmpeg.png create mode 100644 resources/icons/Gear.png create mode 100644 resources/icons/GreenCircle.png create mode 100644 resources/icons/Monitor.png create mode 100644 resources/icons/RedSquare.png create mode 100644 resources/icons/SearchFolder.png create mode 100644 resources/icons/Server.png create mode 100644 resources/icons/SoftwareInstaller.png create mode 100644 resources/icons/StopSign.png create mode 100644 resources/icons/Synchronize.png create mode 100644 resources/icons/Trash.png create mode 100644 src/init.py create mode 100644 src/ui/__init__.py create mode 100644 src/ui/add_job.py create mode 100644 src/ui/console.py create mode 100644 src/ui/engine_browser.py create mode 100644 src/ui/log_viewer.py create mode 100644 src/ui/main_window.py create mode 100644 src/ui/widgets/__init__.py create mode 100644 src/ui/widgets/dialog.py create mode 100644 src/ui/widgets/menubar.py create mode 100644 src/ui/widgets/proportional_image_label.py create mode 100644 src/ui/widgets/statusbar.py create mode 100644 src/ui/widgets/toolbar.py create mode 100644 src/ui/widgets/treeview.py diff --git a/config/config.yaml b/config/config.yaml index 7dece20..94469b4 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -3,6 +3,7 @@ update_engines_on_launch: true max_content_path: 100000000 server_log_level: info log_buffer_length: 250 +subjob_connection_timeout: 120 flask_log_level: error flask_debug_enable: false queue_eval_seconds: 1 diff --git a/dashboard.py b/dashboard.py index 4d84a0e..cfe39d8 100755 --- a/dashboard.py +++ b/dashboard.py @@ -202,7 +202,7 @@ if __name__ == '__main__': start_server_input = input("Local server not running. Start server? (y/n) ") if start_server_input and start_server_input[0].lower() == "y": # Startup the local server - start_server(background_thread=True) + start_server() test = server_proxy.connect() print(f"connected? {test}") else: diff --git a/main.py b/main.py new file mode 100644 index 0000000..c46883d --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +''' main.py ''' +from src import init + +if __name__ == '__main__': + import sys + sys.exit(init.run()) diff --git a/requirements.txt b/requirements.txt index 8875d0f..4b2c6b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ requests==2.31.0 -requests_toolbelt==1.0.0 psutil==5.9.6 PyYAML==6.0.1 Flask==3.0.0 @@ -12,6 +11,6 @@ Pillow==10.1.0 zeroconf==0.119.0 Pypubsub~=4.0.3 tqdm==4.66.1 -dmglib==0.9.4 plyer==2.1.0 -pyobjus==1.2.3 \ No newline at end of file +PyQt6~=6.5.3 +PySide6~=6.6.0 \ No newline at end of file diff --git a/resources/Rectangle.png b/resources/Rectangle.png new file mode 100644 index 0000000000000000000000000000000000000000..730d704d164af9906feacd45bee0cc177e280de5 GIT binary patch literal 3169 zcmd5ZcSpAEwp(eHDYY!2#!EUob9Q&=?#wVV z?QV;gQhb0WycoO&nm$+)gZjV=B&3R_3Go&eQ4+l1i_!)y)TEk5>*dT|yQZL$lscKo zp8w4E|KEQ*|7H%fw{2Njc~2z(U}bAdumb?wjP+D3M>`*#`*sNZ*tM2N4FFbDTaOLA zdbkFFvKcwlX?F5kIZ;hG1xXDKmwWqmP*7GgG>4Gj9m_`t!)y=G7z)dj|VJ5 ztdnoYnp7QP9w$wR6h&bkpVKFbqDz84b`!=>3`0^ZNxKNz#ZexPVX)kVBSKw@a2>%1 zb96}Z<1y3JIFd{zlg=dTRP`uH`+Po+B?KtDJYV2g(3eD_Kw+58y(o-aLKZ|Dp*{_g9nesFbP+c9A~)Ar(P&(g z4h2)yL#i6j*Q!15GRCk@7IW}|C@WSF8|SIOpkP8jj_Sq|G)+)+h-Nv)$FVMga&r`w zh4QK-NBS0mvNY#HYV)9|ZzRDK{t+yRTtw9q0t#792vJCCN)*QmiR7BpxT+&!< zk5${ezVsjUx9KuEUPAo3?O2D-Y6`AJHc+|xa%W;Y+?`v+Wh~PNoFH2L?Z-u{uu#Ht z+wyg1AZs=mgUIML%AJK7YQ#(mIt)Znw*Ey!k;r}0I*Zvz$iD}fc{lIe=HX}e8a;7mQc<8IH;mg#oCwCej?rLP^_ zuE*`8R0UmAec`e7C-1MGt>r)Tm4o#0BkKVOl!LOhV6_Pv(l&G#Zm-le@*=cuxuE|pWSw`@1100CVLLH zXYR+l>OZ!1&D>e_({0J4yNCAg^V=KtKKqgP7tY=xd z=qZwmki}F%luN)`659=nwS*NVwJ6Uog4HYYJMEEeHfEH|_$GqFpv z=g+=EgG0Xr(`!!LZldFR?~48hb`DlGj1E`yH|^Q8dZ4oR$(w-I_~+=v7vARa_dZ1v z;-%>i#@;yn$=tc&vXN6~>|<}8z4+WS$2Qm;({=CLzp>^88p>T@<3#)paLS$^)QdWW sOXuO${~jI&OWpG&eSn0V{K8YV7o85r!I%Gd#QF$mZEg#`-Pkqw7cHP`qW}N^ literal 0 HcmV?d00001 diff --git a/resources/icons/AddProduct.png b/resources/icons/AddProduct.png new file mode 100644 index 0000000000000000000000000000000000000000..f0651b9b2b63ec6a0184f1caf25e92ed123c89a3 GIT binary patch literal 1428 zcmb`HSv1=T6o>yov=Wg9tu}ToWvFgCr4&(1VhMw$wWKmq(oS_OK@8zoIz>cVx=7Vl zQ%g4`qpdXyiVRIn6^W1&v_|cfu>`5nmwBA0Ilu3BzqfnNeYx2_ST`612>}2AhCw^~ zZkzC}JC(QRID=xnE#*)TH)mk$TgqCiZUX=qgKTd0<%-@v;sCc^)t$Loqk$LJ_2)_*1Km-zTsltnh_EgQq zYy392mRi5$6Iij?SX(@ECg4yHbF}onn3=?SU7Qp?&yy$4^IMMx`DnWQ@Gmql&qSbu z4}NoG0pzXNpN{#=`UF{o-Hv;?5q59%;ng5_9=g)haTty~G?=`~bTgo|q2-EHx&5rj7=V+H^=u??ys& zXqoMiajd@wD(cd6E;wfT@pg=LH9B=Kc)$t0{FS#?ORJzrEzqC#yA*StZe~j}tlPwW zM%X40CA^o*j#QhS9v+S}KTDQSP(z`8?_pi#QOl*96PpItdqiUgGxVuI-t*(`B)t$y z6h7d|BnPaM(>5UJ2K42ITMg9g{j(~E$C606sNw@Z((Ne8@88~Qs%(mi2`W=4PhOm; z-bgF86w7Qx)(!&gK&zSz2ih>Z2HsjqtvygJR`+v1)>XdPZNUgE#24w;H{T8Lwk1t_ zS6zK^m1;6^WuE}o-|*`9R@NMP{5)azErxGCrX=)OZcl-sN*(*rFdI5a%acEK2wDwM zQO1ED-~e;U%fkzHU-{6+H=lakjG+w{6ff@g)ltN{(;PL^{MND*vx4J znX@xh2OXNXbHFvEsIPeP;!PDm$`41M*B5u0fSGxiDZR_f-ZQe*ik6w7oCG8I=Tz#78DZJa?6Z*{pb ze<6|JXd=6fHb94Q&v9VEBC|)tK*Daxs{1P+vo#2#w{}kReRPEc+-qePziy&B8OnuL zFVzUQm+)W6s5?|a{c?9>(>)ABe#}A~=lq5&#U76C%(5x)1{d)rMxu$2PhPV9HOZ(X z_M;_d3`XFvW78iFH}^(pEc$&NStAa}?&58rx6Xa$E-LkPbjMm5B2-U^T|}23aj3{_ z=a!a15VP2xkm0=#+&SHT6W18MKp{BUsVj!Mw!wUqW`I<_S5+cdO~~J$#>v!OKDb_n zRstHL#Y&yjb7Y;pS()ug>iRTS$C-PBD7rAr7EZjlF;x&xPSJ~E`%1qID`rK!{E&QR z!~D6eo4#ow1nqOQg1RGW#Azr5{_f@+*8Y5~LgCO!c)^=CZI@nN^z4drfJF;S`k0>( z(|%2C0jHH{oPHf{x8W%Ln9me)mQtIq>@Cq3jjV(Q{ekC}Z>XUwE2HO>>9dN|Wa5yc zgKKaZJ|}sd;mZ c?EW-c!MMOc@Qc%j?KcZxT(HhHPK5M-0J9ya=>Px# literal 0 HcmV?d00001 diff --git a/resources/icons/Adobe After Effects.png b/resources/icons/Adobe After Effects.png new file mode 100644 index 0000000000000000000000000000000000000000..04815b596aec9e566c2b2e1e36372512b00e942f GIT binary patch literal 2005 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 T@BQ$vfTgCSt@z{Ro3Q@@RZldG literal 0 HcmV?d00001 diff --git a/resources/icons/Console.png b/resources/icons/Console.png new file mode 100644 index 0000000000000000000000000000000000000000..1f94d20860ce85bb29a4243c8a74cfca28d17489 GIT binary patch literal 921 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGoY)RhkE)4%caKYZ?lNlJ8{XAV9 zLn`LHz2omOxlrc#$Mc)*?aV?x@$bAe;nJz>E|2RkgaqAO+_X4lTN;HbUI>(MShc8} zDQIC)JEbAq&3938SE!4NYkLb%(8RQjT`Ol7_g7nmDCInTwt3$B_yG)-Zo~^#AI&9RC{!)EkOUrhZ|aw5zXwv*MZ7{md60>gqa%x?0L~O}e=2t|Xs< zsdf9z3)8P2zr64~LqqJ;dp+0Qp8KGyA@Xn9r#s6{&#&E{S8jQO`O6jY&`pus%?~r~ zS*ogeK37}!T*cjYJntDpMV~S>1pLr$m@mF3%f0g0>$2%z&px<%B;}QDYuMZMYfrwK z^{KkSfKA{S!y%shLuZ#q7aJa5R%&gL^rEJYsl^Z~CE*~!%#qBH$fMB1h+VLgwS(=~ zhM-{GuivIAiaBKGIG9+p+-UuL!TZf`XWfg@6W*05757HXM@+ zh-j!-&dUFd(ZZ||kYGn{l9j$dbe{-k|Nc2upb zm>Zkm?h?V1n~Wx_8&8j5xc*uE!zS)QUfXiqVtM+4Z@+x! zov}$`+;WWV$nNurORLInm8yz4-zyM!^*~_5oL!dx^^Y*QFxUTxW{qwCeb9NuzdDOU ziN)^OdGAsgEQ9X+o;B{!Xh*C&IY#D6_(XsT=4q}svyp`=vWD*n~Hx!Kcx ztL2#EjxO|w{qy{e%wzlKc}5^Duw>og^NIQ1u?r$x)v}*~d5yu-)z4*}Q$iB}nTDRO literal 0 HcmV?d00001 diff --git a/resources/icons/Document.png b/resources/icons/Document.png new file mode 100644 index 0000000000000000000000000000000000000000..7f7f7f3bac7b32e3c881d4877dd5659bf9bad0e5 GIT binary patch literal 476 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGoY)RhkE)4%caKYZ?lNlHoCwjU# zhE&XXd-I^Dv!jUHLwl_o4GHYK3UbxO9_dX>DARP=z#qGN!;}X~c|0u>GE`2^;r?F3 z>Z2x;ruTlY%OsVRi%%}(K678}baGD049~Rw@6-Lin1!8t!OOG#Ov%4}70b47yrl0C zq~$c5q5QgZ{Z@|_Gse7i=e*rAgcVjwE(~p064z43I4Mdnip}GdONO+>Lic864o(YGS-5Ajq>Jv4n*=$Iz&Wu}nhrAj5tM!GjF-7NsoAZyrto$}W!;#FT~Uea<>SAIZTrM>fQP}D;S7TTGZw~&?F%Xr?x)@J7-ldg zur)k1ZrD}5`U6BK&wR$k^O#qilk$fc@b%7 literal 0 HcmV?d00001 diff --git a/resources/icons/Download.png b/resources/icons/Download.png new file mode 100644 index 0000000000000000000000000000000000000000..170be352198c84b481735f754b8536cd1746074b GIT binary patch literal 979 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 literal 0 HcmV?d00001 diff --git a/resources/icons/RedSquare.png b/resources/icons/RedSquare.png new file mode 100644 index 0000000000000000000000000000000000000000..9f7c1e4b6e79009c9f5a87214d84b43a4c00c742 GIT binary patch literal 1373 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 1) and not worker.parent: + if job_data.get("enable_split_jobs", False) and (worker.total_frames > 1) and not worker.parent: DistributedJobManager.split_into_subjobs(worker, job_data, loaded_project_local_path) else: logger.debug("Not splitting into subjobs") diff --git a/src/api/api_server.py b/src/api/api_server.py index 8eecda4..0eb87a8 100755 --- a/src/api/api_server.py +++ b/src/api/api_server.py @@ -24,7 +24,8 @@ from src.engines.core.base_worker import string_to_status, RenderStatus from src.engines.engine_manager import EngineManager from src.render_queue import RenderQueue, JobNotFoundError from src.utilities.config import Config -from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu, current_system_os_version +from src.utilities.misc_helper import system_safe_path, current_system_os, current_system_cpu, \ + current_system_os_version, config_dir from src.utilities.server_helper import generate_thumbnail_for_job from src.utilities.zeroconf_server import ZeroconfServer @@ -53,7 +54,7 @@ def sorted_jobs(all_jobs, sort_by_date=True): @server.route('/') @server.route('/index') def index(): - with open(system_safe_path('config/presets.yaml')) as f: + with open(system_safe_path(os.path.join(config_dir(), 'presets.yaml'))) as f: render_presets = yaml.load(f, Loader=yaml.FullLoader) return render_template('index.html', all_jobs=sorted_jobs(RenderQueue.all_jobs()), @@ -312,8 +313,7 @@ def add_job_handler(): if loaded_project_local_path.lower().endswith('.zip'): loaded_project_local_path = process_zipped_project(loaded_project_local_path) - results = create_render_jobs(jobs_list, loaded_project_local_path, referred_name, - server.config['enable_split_jobs']) + results = create_render_jobs(jobs_list, loaded_project_local_path, referred_name) for response in results: if response.get('error', None): return results, 400 @@ -417,18 +417,18 @@ def status(): @server.get('/api/renderer_info') def renderer_info(): + return_simple = request.args.get('simple', False) renderer_data = {} - for engine_name in EngineManager.supported_engines(): - engine = EngineManager.engine_with_name(engine_name) - + for engine in EngineManager.supported_engines(): # Get all installed versions of engine - installed_versions = EngineManager.all_versions_for_engine(engine_name) + installed_versions = EngineManager.all_versions_for_engine(engine.name()) if installed_versions: install_path = installed_versions[0]['path'] - renderer_data[engine_name] = {'is_available': RenderQueue.is_available_for_job(engine.name()), - 'versions': installed_versions, - 'supported_extensions': engine.supported_extensions, - 'supported_export_formats': engine(install_path).get_output_formats()} + renderer_data[engine.name()] = {'is_available': RenderQueue.is_available_for_job(engine.name()), + 'versions': installed_versions} + if not return_simple: + renderer_data[engine.name()]['supported_extensions'] = engine.supported_extensions + renderer_data[engine.name()]['supported_export_formats'] = engine(install_path).get_output_formats() return renderer_data @@ -471,19 +471,20 @@ def download_engine(): @server.post('/api/delete_engine') def delete_engine_download(): - delete_result = EngineManager.delete_engine_download(request.args.get('engine'), - request.args.get('version'), - request.args.get('system_os'), - request.args.get('cpu')) + json_data = request.json + delete_result = EngineManager.delete_engine_download(json_data.get('engine'), + json_data.get('version'), + json_data.get('system_os'), + json_data.get('cpu')) return "Success" if delete_result else \ - (f"Error deleting {request.args.get('engine')} {request.args.get('version')}", 500) + (f"Error deleting {json_data.get('engine')} {json_data.get('version')}", 500) @server.get('/api/renderer//args') def get_renderer_args(renderer): try: renderer_engine_class = EngineManager.engine_with_name(renderer) - return renderer_engine_class.get_arguments() + return renderer_engine_class().get_arguments() except LookupError: return f"Cannot find renderer '{renderer}'", 400 @@ -499,13 +500,6 @@ def start_server(): RenderQueue.evaluate_queue() time.sleep(delay_sec) - # Load Config YAML - config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'config') - Config.load_config(system_safe_path(os.path.join(config_dir, 'config.yaml'))) - - logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S', - level=Config.server_log_level.upper()) - # get hostname local_hostname = socket.gethostname() local_hostname = local_hostname + (".local" if not local_hostname.endswith(".local") else "") diff --git a/src/api/server_proxy.py b/src/api/server_proxy.py index 295e0ed..043f0c6 100644 --- a/src/api/server_proxy.py +++ b/src/api/server_proxy.py @@ -34,9 +34,14 @@ class RenderServerProxy: self.__offline_flags = 0 self.update_cadence = 5 + # Cache some basic server info + self.system_cpu = None + self.system_cpu_count = None + self.system_os = None + self.system_os_version = None + def connect(self): - status = self.request_data('status') - return status + return self.status() def is_online(self): if self.__update_in_background: @@ -48,7 +53,7 @@ class RenderServerProxy: if not self.is_online(): return "Offline" running_jobs = [x for x in self.__jobs_cache if x['status'] == 'running'] if self.__jobs_cache else [] - return f"{len(running_jobs)} running" if running_jobs else "Available" + return f"{len(running_jobs)} running" if running_jobs else "Ready" def request_data(self, payload, timeout=5): try: @@ -72,6 +77,8 @@ class RenderServerProxy: return requests.get(f'http://{self.hostname}:{self.port}/api/{payload}', timeout=timeout) def start_background_update(self): + if self.__update_in_background: + return self.__update_in_background = True def thread_worker(): @@ -113,12 +120,24 @@ class RenderServerProxy: def cancel_job(self, job_id, confirm=False): return self.request_data(f'job/{job_id}/cancel?confirm={confirm}') + def delete_job(self, job_id, confirm=False): + return self.request_data(f'job/{job_id}/delete?confirm={confirm}') + def get_status(self): - return self.request_data('status') + status = self.request_data('status') + if not self.system_cpu: + self.system_cpu = status['system_cpu'] + self.system_cpu_count = status['cpu_count'] + self.system_os = status['system_os'] + self.system_os_version = status['system_os_version'] + return status def is_engine_available(self, engine_name): return self.request_data(f'{engine_name}/is_available') + def get_all_engines(self): + return self.request_data('all_engines') + def notify_parent_of_status_change(self, parent_id, subjob): return requests.post(f'http://{self.hostname}:{self.port}/api/job/{parent_id}/notify_parent_of_status_change', json=subjob.json()) @@ -160,3 +179,12 @@ class RenderServerProxy: f.write(chunk) return filename + # --- Renderer --- # + + def get_renderer_info(self, timeout=5, simple=False): + all_data = self.request_data(f'renderer_info?simple={simple}', timeout=timeout) + return all_data + + def delete_engine(self, engine, version, system_cpu=None): + form_data = {'engine': engine, 'version': version, 'system_cpu': system_cpu} + return requests.post(f'http://{self.hostname}:{self.port}/api/delete_engine', json=form_data) diff --git a/src/engines/blender/blender_downloader.py b/src/engines/blender/blender_downloader.py index 210cd29..83ba6c5 100644 --- a/src/engines/blender/blender_downloader.py +++ b/src/engines/blender/blender_downloader.py @@ -108,7 +108,7 @@ class BlenderDownloader(EngineDownloader): minor_versions = [x for x in cls.__get_minor_versions(major_version, system_os, cpu) if x['version'] == version] # we get the URL instead of calculating it ourselves. May change this - cls.__download_and_extract_app(remote_url=minor_versions[0]['url'], download_location=download_location, + cls.download_and_extract_app(remote_url=minor_versions[0]['url'], download_location=download_location, timeout=timeout) except IndexError: logger.error("Cannot find requested engine") diff --git a/src/engines/core/base_downloader.py b/src/engines/core/base_downloader.py index 1d925b7..4867f01 100644 --- a/src/engines/core/base_downloader.py +++ b/src/engines/core/base_downloader.py @@ -31,7 +31,7 @@ class EngineDownloader: raise NotImplementedError # implement this method in your engine subclass @classmethod - def __download_and_extract_app(cls, remote_url, download_location, timeout=120): + def download_and_extract_app(cls, remote_url, download_location, timeout=120): # Create a temp download directory temp_download_dir = tempfile.mkdtemp() @@ -154,5 +154,7 @@ def copy_directory_contents(src_dir, dest_dir): # Otherwise, copy the file shutil.copy2(item_path, dest_item_path) + except PermissionError as ex: + logger.error(f"Permissions error: {ex}") except Exception as e: logger.exception(f"Error copying directory contents: {e}") diff --git a/src/engines/engine_manager.py b/src/engines/engine_manager.py index 9500bd0..bb96a7c 100644 --- a/src/engines/engine_manager.py +++ b/src/engines/engine_manager.py @@ -170,14 +170,22 @@ class EngineManager: @classmethod def delete_engine_download(cls, engine, version, system_os=None, cpu=None): logger.info(f"Requested deletion of engine: {engine}-{version}") + found = cls.is_version_downloaded(engine, version, system_os, cpu) - if found: - dir_path = os.path.dirname(found['path']) - shutil.rmtree(dir_path, ignore_errors=True) + if found and found['type'] == 'managed': # don't delete system installs + # find the root directory of the engine executable + root_dir_name = '-'.join([engine, version, found['system_os'], found['cpu']]) + remove_path = os.path.join(found['path'].split(root_dir_name)[0], root_dir_name) + # delete the file path + logger.info(f"Deleting engine at path: {remove_path}") + shutil.rmtree(remove_path, ignore_errors=False) logger.info(f"Engine {engine}-{version}-{found['system_os']}-{found['cpu']} successfully deleted") return True + elif found: # these are managed by the system / user. Don't delete these. + logger.error(f'Cannot delete requested {engine} {version}. Managed externally.') else: logger.error(f"Cannot find engine: {engine}-{version}") + return False @classmethod def update_all_engines(cls): @@ -233,6 +241,13 @@ class EngineManager: return worker_class(input_path=input_path, output_path=output_path, engine_path=engine_path, args=args, parent=parent, name=name) + @classmethod + def engine_for_project_path(cls, path): + name, extension = os.path.splitext(path) + for engine in cls.supported_engines(): + if extension in engine.supported_extensions: + return engine + if __name__ == '__main__': logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') diff --git a/src/engines/ffmpeg/ffmpeg_downloader.py b/src/engines/ffmpeg/ffmpeg_downloader.py index 3c36b49..638a374 100644 --- a/src/engines/ffmpeg/ffmpeg_downloader.py +++ b/src/engines/ffmpeg/ffmpeg_downloader.py @@ -162,7 +162,7 @@ class FFMPEGDownloader(EngineDownloader): # Download and extract try: logger.info(f"Requesting download of ffmpeg-{version}-{system_os}-{cpu}") - cls.__download_and_extract_app(remote_url=remote_url, download_location=download_location, timeout=timeout) + cls.download_and_extract_app(remote_url=remote_url, download_location=download_location, timeout=timeout) # naming cleanup to match existing naming convention output_path = os.path.join(download_location, f'ffmpeg-{version}-{system_os}-{cpu}') diff --git a/src/init.py b/src/init.py new file mode 100644 index 0000000..269c842 --- /dev/null +++ b/src/init.py @@ -0,0 +1,65 @@ +''' app/init.py ''' +import logging +import os +import sys +import threading +from collections import deque + +from PyQt6.QtCore import QObject, pyqtSignal +from PyQt6.QtWidgets import QApplication +from .ui.main_window import MainWindow + +from src.api.api_server import start_server +from src.utilities.config import Config +from src.utilities.misc_helper import system_safe_path + + +def run() -> int: + """ + Initializes the application and runs it. + + Returns: + int: The exit status code. + """ + + # Load Config YAML + config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config') + Config.load_config(system_safe_path(os.path.join(config_dir, 'config.yaml'))) + + logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S', + level=Config.server_log_level.upper()) + + app: QApplication = QApplication(sys.argv) + + # Start server in background + background_server = threading.Thread(target=start_server) + background_server.daemon = True + background_server.start() + + # Setup logging for console ui + buffer_handler = BufferingHandler() + buffer_handler.setFormatter(logging.getLogger().handlers[0].formatter) + logger = logging.getLogger() + logger.addHandler(buffer_handler) + + window: MainWindow = MainWindow() + window.buffer_handler = buffer_handler + window.show() + return sys.exit(app.exec()) + + +class BufferingHandler(logging.Handler, QObject): + new_record = pyqtSignal(str) + + def __init__(self, capacity=100): + logging.Handler.__init__(self) + QObject.__init__(self) + self.buffer = deque(maxlen=capacity) # Define a buffer with a fixed capacity + + def emit(self, record): + msg = self.format(record) + self.buffer.append(msg) # Add message to the buffer + self.new_record.emit(msg) # Emit signal + + def get_buffer(self): + return list(self.buffer) # Return a copy of the buffer diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/add_job.py b/src/ui/add_job.py new file mode 100644 index 0000000..7c441bc --- /dev/null +++ b/src/ui/add_job.py @@ -0,0 +1,289 @@ +import os.path +import socket +import threading + +import psutil +from PyQt6.QtWidgets import ( + QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QSpinBox, QComboBox, + QGroupBox, QCheckBox, QProgressBar, QPlainTextEdit +) + +from src.api.server_proxy import RenderServerProxy +from src.engines.engine_manager import EngineManager +from src.utilities.zeroconf_server import ZeroconfServer + + +class NewRenderJobForm(QWidget): + def __init__(self): + super().__init__() + + # UI + self.raw_args = None + self.submit_progress_label = None + self.submit_progress = None + self.renderer_type = None + self.process_label = None + self.process_progress_bar = None + self.splitjobs_same_os = None + self.enable_splitjobs = None + self.server_input = None + self.submit_button = None + self.notes_input = None + self.priority_input = None + self.end_frame_input = None + self.start_frame_input = None + self.output_path_browse_button = None + self.output_path_input = None + self.scene_file_input = None + self.scene_file_browse_button = None + self.job_name_input = None + + # Setup + self.setWindowTitle("New Job") + self.setup_ui() + + # Job / Server Data + self.server_proxy = RenderServerProxy(socket.gethostname()) + self.renderer_info = None + self.project_info = None + + # get renderer info in bg thread + t = threading.Thread(target=self.update_renderer_info) + t.start() + + self.show() + + def setup_ui(self): + # Main Layout + main_layout = QVBoxLayout(self) + + # Server Group + # Server List + server_group = QGroupBox("Server") + server_layout = QVBoxLayout(server_group) + server_list_layout = QHBoxLayout() + server_list_layout.setSpacing(0) + self.server_input = QComboBox() + server_list_layout.addWidget(QLabel("Hostname:"), 1) + server_list_layout.addWidget(self.server_input, 3) + server_layout.addLayout(server_list_layout) + main_layout.addWidget(server_group) + self.update_server_list() + # Priority + priority_layout = QHBoxLayout() + priority_layout.addWidget(QLabel("Priority:"), 1) + self.priority_input = QComboBox() + self.priority_input.addItems(["High", "Medium", "Low"]) + self.priority_input.setCurrentIndex(1) + priority_layout.addWidget(self.priority_input, 3) + server_layout.addLayout(priority_layout) + # Splitjobs + self.enable_splitjobs = QCheckBox("Automatically split render across multiple servers") + self.enable_splitjobs.setEnabled(True) + server_layout.addWidget(self.enable_splitjobs) + self.splitjobs_same_os = QCheckBox("Only render on same OS") + self.splitjobs_same_os.setEnabled(True) + server_layout.addWidget(self.splitjobs_same_os) + + # Scene File Group + scene_file_group = QGroupBox("Project") + scene_file_layout = QVBoxLayout(scene_file_group) + scene_file_picker_layout = QHBoxLayout() + self.scene_file_input = QLineEdit() + self.scene_file_browse_button = QPushButton("Browse...") + self.scene_file_browse_button.clicked.connect(self.browse_scene_file) + scene_file_picker_layout.addWidget(self.scene_file_input) + scene_file_picker_layout.addWidget(self.scene_file_browse_button) + scene_file_layout.addLayout(scene_file_picker_layout) + # progress bar + progress_layout = QHBoxLayout() + self.process_progress_bar = QProgressBar() + self.process_progress_bar.setMinimum(0) + self.process_progress_bar.setMaximum(0) + self.process_progress_bar.setHidden(True) + self.process_label = QLabel("Processing") + self.process_label.setHidden(True) + progress_layout.addWidget(self.process_label) + progress_layout.addWidget(self.process_progress_bar) + scene_file_layout.addLayout(progress_layout) + main_layout.addWidget(scene_file_group) + + # Output Settings Group + output_settings_group = QGroupBox("Output Settings") + output_settings_layout = QVBoxLayout(output_settings_group) + frame_range_layout = QHBoxLayout(output_settings_group) + self.start_frame_input = QSpinBox() + self.start_frame_input.setRange(1, 99999) + self.end_frame_input = QSpinBox() + self.end_frame_input.setRange(1, 99999) + frame_range_layout.addWidget(QLabel("Frames:")) + frame_range_layout.addWidget(self.start_frame_input) + frame_range_layout.addWidget(QLabel("to")) + frame_range_layout.addWidget(self.end_frame_input) + output_settings_layout.addLayout(frame_range_layout) + # output path + output_path_layout = QHBoxLayout() + output_path_layout.addWidget(QLabel("Render name:")) + self.output_path_input = QLineEdit() + # self.output_path_browse_button = QPushButton("Browse...") + # self.output_path_browse_button.clicked.connect(self.browse_output_path) + output_path_layout.addWidget(self.output_path_input) + output_path_layout.addWidget(self.output_path_browse_button) + output_settings_layout.addLayout(output_path_layout) + main_layout.addWidget(output_settings_group) + + # Renderer Group + renderer_group = QGroupBox("Renderer Settings") + renderer_layout = QVBoxLayout(renderer_group) + self.renderer_type = QComboBox() + renderer_layout.addWidget(self.renderer_type) + # Raw Args + raw_args_layout = QHBoxLayout(renderer_group) + raw_args_layout.addWidget(QLabel("Raw Args:")) + self.raw_args = QLineEdit() + raw_args_layout.addWidget(self.raw_args) + args_help_button = QPushButton("?") + args_help_button.clicked.connect(self.args_help_button_clicked) + raw_args_layout.addWidget(args_help_button) + renderer_layout.addLayout(raw_args_layout) + main_layout.addWidget(renderer_group) + + # Notes Group + notes_group = QGroupBox("Additional Notes") + notes_layout = QVBoxLayout(notes_group) + self.notes_input = QPlainTextEdit() + notes_layout.addWidget(self.notes_input) + main_layout.addWidget(notes_group) + + # Submit Button + self.submit_button = QPushButton("Submit Job") + self.submit_button.clicked.connect(self.submit_job) + main_layout.addWidget(self.submit_button) + + self.submit_progress = QProgressBar() + self.submit_progress.setMinimum(0) + self.submit_progress.setMaximum(0) + self.submit_progress.setHidden(True) + main_layout.addWidget(self.submit_progress) + + self.submit_progress_label = QLabel("Submitting...") + self.submit_progress_label.setHidden(True) + main_layout.addWidget(self.submit_progress_label) + + self.toggle_renderer_enablement(False) + + def update_renderer_info(self): + self.renderer_info = self.server_proxy.get_renderer_info() + self.renderer_type.addItems(self.renderer_info.keys()) + + def update_server_list(self): + clients = ZeroconfServer.found_clients() + self.server_input.clear() + self.server_input.addItems(clients) + + def browse_scene_file(self): + + def get_project_info(): + self.process_progress_bar.setHidden(False) + self.process_label.setHidden(False) + self.toggle_renderer_enablement(False) + output_name, _ = os.path.splitext(os.path.basename(file_name)) + self.output_path_input.setText(output_name) + + engine = EngineManager.engine_for_project_path(file_name) + self.project_info = engine().get_scene_info(file_name) + + index = self.renderer_type.findText(engine.name().lower()) + if index >= 0: + self.renderer_type.setCurrentIndex(index) + + self.update_project_ui() + + self.process_progress_bar.setHidden(True) + self.process_label.setHidden(True) + self.toggle_renderer_enablement(True) + + file_name, _ = QFileDialog.getOpenFileName(self, "Select Scene File") + if file_name: + self.scene_file_input.setText(file_name) + # analyze the file + update_thread = threading.Thread(target=get_project_info) + update_thread.start() + + + def browse_output_path(self): + directory = QFileDialog.getExistingDirectory(self, "Select Output Directory") + if directory: + self.output_path_input.setText(directory) + + def args_help_button_clicked(self): + #todo: create a popup window showing args + pass + +# -------- Update -------- + + def update_project_ui(self): + self.start_frame_input.setValue(self.project_info.get('frame_start')) + self.end_frame_input.setValue(self.project_info.get('frame_end')) + + def toggle_renderer_enablement(self, enabled=False): + self.start_frame_input.setEnabled(enabled) + self.end_frame_input.setEnabled(enabled) + self.notes_input.setEnabled(enabled) + self.output_path_input.setEnabled(enabled) + self.submit_button.setEnabled(enabled) + +# -------- Submit Job Calls -------- + + def submit_job(self): + def submit_job_worker(): + def create_callback(encoder): + encoder_len = encoder.len + def callback(monitor): + percent = f"{monitor.bytes_read / encoder_len * 100:.0f}" + self.submit_progress_label.setText(f"Transferring to {hostname} - {percent}%") + self.submit_progress.setMaximum(100) + self.submit_progress.setValue(int(percent)) + + return callback + + self.submit_progress.setHidden(False) + self.submit_progress_label.setHidden(False) + self.submit_button.setHidden(True) + + hostname = self.server_input.currentText() + job_json = {'owner': psutil.Process().username() + '@' + socket.gethostname(), + 'renderer': self.renderer_type.currentText().lower(), + # 'input_path': self.scene_file_input.text(), + # 'output_path': os.path.join(os.path.dirname(self.chosen_file), self.output_entry.get()), + 'args': {'raw': self.raw_args.text()}, + 'output_path': self.output_path_input.text(), + 'start_frame': self.start_frame_input.value(), + 'end_frame': self.end_frame_input.value(), + 'priority': self.priority_input.currentIndex() + 1, + 'notes': self.notes_input.toPlainText(), + 'enable_split_jobs': self.enable_splitjobs.isChecked()} + + input_path = self.scene_file_input.text() + job_list = [job_json] + self.submit_progress.setMaximum(0) + result = self.server_proxy.post_job_to_server(file_path=input_path, job_list=job_list, + callback=create_callback) + self.submit_progress.setMaximum(0) + + print(result.json()) + self.submit_button.setHidden(False) + self.submit_progress.setHidden(True) + self.submit_progress_label.setHidden(True) + + + + # submit thread + worker_thread = threading.Thread(target=submit_job_worker) + worker_thread.start() + +# Run the application +if __name__ == '__main__': + app = QApplication([]) + window = NewRenderJobForm() + app.exec() diff --git a/src/ui/console.py b/src/ui/console.py new file mode 100644 index 0000000..a469b23 --- /dev/null +++ b/src/ui/console.py @@ -0,0 +1,60 @@ +import sys +import logging + +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import QMainWindow, QVBoxLayout, QWidget, QPlainTextEdit +from PyQt6.QtCore import pyqtSignal, QObject + + +# Create a custom logging handler that emits a signal +class QSignalHandler(logging.Handler, QObject): + new_record = pyqtSignal(str) + + def __init__(self): + logging.Handler.__init__(self) + QObject.__init__(self) + + def emit(self, record): + msg = self.format(record) + self.new_record.emit(msg) # Emit signal + + +class ConsoleWindow(QMainWindow): + def __init__(self, buffer_handler): + super().__init__() + self.buffer_handler = buffer_handler + self.log_handler = None + self.init_ui() + self.init_logging() + + def init_ui(self): + self.setGeometry(100, 100, 600, 800) + self.setWindowTitle("Log Output") + + self.text_edit = QPlainTextEdit(self) + self.text_edit.setReadOnly(True) + self.text_edit.setFont(QFont("Courier", 10)) + + layout = QVBoxLayout() + layout.addWidget(self.text_edit) + layout.setContentsMargins(0, 0, 0, 0) + + central_widget = QWidget() + central_widget.setLayout(layout) + self.setCentralWidget(central_widget) + + def init_logging(self): + + self.buffer_handler.new_record.connect(self.append_log_record) + # Display all messages that were buffered before the window was opened + for record in self.buffer_handler.get_buffer(): + self.text_edit.appendPlainText(record) + + self.log_handler = QSignalHandler() + # self.log_handler.new_record.connect(self.append_log_record) + self.log_handler.setFormatter(self.buffer_handler.formatter) + logging.getLogger().addHandler(self.log_handler) + logging.getLogger().setLevel(logging.INFO) + + def append_log_record(self, record): + self.text_edit.appendPlainText(record) diff --git a/src/ui/engine_browser.py b/src/ui/engine_browser.py new file mode 100644 index 0000000..5c14132 --- /dev/null +++ b/src/ui/engine_browser.py @@ -0,0 +1,159 @@ +import os +import socket +import subprocess +import sys +import threading + +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QPushButton, QTableWidget, QTableWidgetItem, QHBoxLayout, QAbstractItemView, + QHeaderView, QProgressBar, QLabel, QMessageBox +) + +from src.api.server_proxy import RenderServerProxy +from src.engines.engine_manager import EngineManager + + +class EngineBrowserWindow(QMainWindow): + def __init__(self, hostname=None): + super().__init__() + self.delete_button = None + self.install_button = None + self.progress_label = None + self.progress_bar = None + self.table_widget = None + self.launch_button = None + self.hostname = hostname or socket.gethostname() + self.setWindowTitle(f'Engine Browser ({self.hostname})') + self.setGeometry(100, 100, 500, 300) + self.engine_data = [] + self.initUI() + + def initUI(self): + # Central widget + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Layout + layout = QVBoxLayout(central_widget) + + # Table + self.table_widget = QTableWidget(0, 4) + self.table_widget.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table_widget.verticalHeader().setVisible(False) + self.table_widget.itemSelectionChanged.connect(self.engine_picked) + self.table_widget.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + layout.addWidget(self.table_widget) + self.update_table() + + # Progress Bar Layout + self.progress_bar = QProgressBar() + self.progress_bar.setMinimum(0) + self.progress_bar.setMaximum(0) + # self.progress_bar.setHidden(True) + layout.addWidget(self.progress_bar) + + # Progress Bar Label + self.progress_label = QLabel('Downloading blah blah') + layout.addWidget(self.progress_label) + + # Buttons Layout + buttons_layout = QHBoxLayout() + + # Install Button + self.install_button = QPushButton('Install') + self.install_button.clicked.connect(self.install_button_click) # Connect to slot + # buttons_layout.addWidget(self.install_button) + + # Launch Button + self.launch_button = QPushButton('Launch') + self.launch_button.clicked.connect(self.launch_button_click) # Connect to slot + self.launch_button.setEnabled(False) + buttons_layout.addWidget(self.launch_button) + + #Delete Button + self.delete_button = QPushButton('Delete') + self.delete_button.clicked.connect(self.delete_button_click) # Connect to slot + self.delete_button.setEnabled(False) + buttons_layout.addWidget(self.delete_button) + + # Add Buttons Layout to the Main Layout + layout.addLayout(buttons_layout) + + self.update_download_status() + + def update_table(self): + + def update_table_worker(): + raw_server_data = RenderServerProxy(self.hostname).get_renderer_info(simple=True) + if not raw_server_data: + return + + table_data = [] # convert the data into a flat list + for engine_name, engine_data in raw_server_data.items(): + table_data.extend(engine_data['versions']) + self.engine_data = table_data + + self.table_widget.setRowCount(len(self.engine_data)) + self.table_widget.setColumnCount(4) + + for row, engine in enumerate(self.engine_data): + self.table_widget.setItem(row, 0, QTableWidgetItem(engine['engine'])) + self.table_widget.setItem(row, 1, QTableWidgetItem(engine['version'])) + self.table_widget.setItem(row, 2, QTableWidgetItem(engine['type'])) + self.table_widget.setItem(row, 3, QTableWidgetItem(engine['path'])) + + self.table_widget.selectRow(0) + + self.table_widget.clear() + self.table_widget.setHorizontalHeaderLabels(['Engine', 'Version', 'Type', 'Path']) + self.table_widget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + self.table_widget.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) + self.table_widget.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) + self.table_widget.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) + update_thread = threading.Thread(target=update_table_worker,) + update_thread.start() + + def engine_picked(self): + engine_info = self.engine_data[self.table_widget.currentRow()] + self.delete_button.setEnabled(engine_info['type'] == 'managed') + self.launch_button.setEnabled(self.hostname == socket.gethostname()) + + def update_download_status(self): + hide_progress = not bool(EngineManager.download_tasks) + self.progress_bar.setHidden(hide_progress) + self.progress_label.setHidden(hide_progress) + + # todo: update progress bar with status + self.progress_label.setText(f"Downloading {len(EngineManager.download_tasks)} engines") + + def launch_button_click(self): + engine_info = self.engine_data[self.table_widget.currentRow()] + path = engine_info['path'] + if sys.platform.startswith('darwin'): + subprocess.run(['open', path]) + elif sys.platform.startswith('win32'): + os.startfile(path) + elif sys.platform.startswith('linux'): + subprocess.run(['xdg-open', path]) + else: + raise OSError("Unsupported operating system") + + def install_button_click(self): + self.update_download_status() + + def delete_button_click(self): + engine_info = self.engine_data[self.table_widget.currentRow()] + reply = QMessageBox.question(self, f"Delete {engine_info['engine']} {engine_info['version']}?", + f"Do you want to delete {engine_info['engine']} {engine_info['version']}?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + + if reply is not QMessageBox.StandardButton.Yes: + return + + result = RenderServerProxy(self.hostname).delete_engine(engine_info['engine'], engine_info['version']) + if result.ok: + self.update_table() + else: + QMessageBox.warning(self, f"Delete {engine_info['engine']} {engine_info['version']} Failed", + f"Failed to delete {engine_info['engine']} {engine_info['version']}.", + QMessageBox.StandardButton.Ok) diff --git a/src/ui/log_viewer.py b/src/ui/log_viewer.py new file mode 100644 index 0000000..9338fd8 --- /dev/null +++ b/src/ui/log_viewer.py @@ -0,0 +1,30 @@ +import requests +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import QMainWindow, QVBoxLayout, QWidget, QPlainTextEdit + + +class LogViewer(QMainWindow): + def __init__(self, log_path): + super().__init__() + + self.log_path = log_path + self.setGeometry(100, 100, 600, 800) + self.setWindowTitle("Log Output") + + self.text_edit = QPlainTextEdit(self) + self.text_edit.setReadOnly(True) + self.text_edit.setFont(QFont("Courier", 10)) + + layout = QVBoxLayout() + layout.addWidget(self.text_edit) + layout.setContentsMargins(0, 0, 0, 0) + + central_widget = QWidget() + central_widget.setLayout(layout) + self.setCentralWidget(central_widget) + + self.fetch_logs() + + def fetch_logs(self): + result = requests.get(self.log_path) + self.text_edit.setPlainText(result.text) diff --git a/src/ui/main_window.py b/src/ui/main_window.py new file mode 100644 index 0000000..99361e9 --- /dev/null +++ b/src/ui/main_window.py @@ -0,0 +1,562 @@ +''' app/ui/main_window.py ''' +import datetime +import logging +import os +import socket +import subprocess +import sys +import threading +import time + +from PIL import Image +from PyQt6.QtCore import Qt, QByteArray, QBuffer, QIODevice, QThread +from PyQt6.QtGui import QPixmap, QImage, QFont, QIcon +from PyQt6.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QListWidget, QTableWidget, QAbstractItemView, \ + QTableWidgetItem, QLabel, QVBoxLayout, QHeaderView, QMessageBox, QGroupBox, QPushButton, QListWidgetItem + +from src.api.server_proxy import RenderServerProxy +from src.render_queue import RenderQueue +from src.utilities.misc_helper import get_time_elapsed, resources_dir +from src.utilities.status_utils import RenderStatus +from src.utilities.zeroconf_server import ZeroconfServer +from .add_job import NewRenderJobForm +from .console import ConsoleWindow +from .engine_browser import EngineBrowserWindow +from .log_viewer import LogViewer +from .widgets.menubar import MenuBar +from .widgets.proportional_image_label import ProportionalImageLabel +from .widgets.statusbar import StatusBar +from .widgets.toolbar import ToolBar + +logger = logging.getLogger() + + +class MainWindow(QMainWindow): + """ + MainWindow + + Args: + QMainWindow (QMainWindow): Inheritance + """ + + def __init__(self) -> None: + """ + Initialize the Main-Window. + """ + super().__init__() + + # Load the queue + self.engine_browser_window = None + self.server_info_group = None + self.server_proxies = {} + self.current_hostname = None + self.subprocess_runner = None + + # To pass to console + self.buffer_handler = None + + # Window-Settings + self.setWindowTitle("Zordon") + self.setGeometry(100, 100, 900, 800) + central_widget = QWidget(self) + self.setCentralWidget(central_widget) + + main_layout = QHBoxLayout(central_widget) + + # Create a QLabel widget to display the image + self.image_label = ProportionalImageLabel() + self.image_label.setMaximumSize(700, 500) + self.image_label.setFixedHeight(500) + self.image_label.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter) + self.load_image_path(os.path.join(resources_dir(), 'Rectangle.png')) + + # Server list + self.server_list_view = QListWidget() + self.server_list_view.itemClicked.connect(self.server_picked) + list_font = QFont() + list_font.setPointSize(16) + self.server_list_view.setFont(list_font) + self.added_hostnames = [] + + self.setup_ui(main_layout) + + self.create_toolbars() + + # Add Widgets to Window + self.setMenuBar(MenuBar(self)) + self.setStatusBar(StatusBar(self)) + + # start background update + self.bg_update_thread = QThread() + self.bg_update_thread.run = self.__background_update + self.bg_update_thread.start() + + # Setup other windows + self.new_job_window = None + self.console_window = None + self.log_viewer_window = None + + # Pick default job + self.job_picked() + + def setup_ui(self, main_layout): + + # Servers + server_list_group = QGroupBox("Available Servers") + list_layout = QVBoxLayout() + list_layout.addWidget(self.server_list_view) + list_layout.setContentsMargins(0, 0, 0, 0) + server_list_group.setLayout(list_layout) + server_info_group = QGroupBox("Server Info") + + # Server Info Group + self.server_info_hostname = QLabel() + self.server_info_os = QLabel() + self.server_info_cpu = QLabel() + self.server_info_ram = QLabel() + server_info_engines_button = QPushButton("Render Engines") + server_info_engines_button.clicked.connect(self.engine_browser) + server_info_layout = QVBoxLayout() + server_info_layout.addWidget(self.server_info_hostname) + server_info_layout.addWidget(self.server_info_os) + server_info_layout.addWidget(self.server_info_cpu) + server_info_layout.addWidget(self.server_info_ram) + server_info_layout.addWidget(server_info_engines_button) + server_info_group.setLayout(server_info_layout) + + # Server Button Layout + server_button_layout = QHBoxLayout() + add_server_button = QPushButton(text="+") + remove_server_button = QPushButton(text="-") + server_button_layout.addWidget(add_server_button) + server_button_layout.addWidget(remove_server_button) + + # Layouts + info_layout = QVBoxLayout() + info_layout.addWidget(server_list_group, stretch=True) + info_layout.addWidget(server_info_group) + info_layout.setContentsMargins(0, 0, 0, 0) + server_list_group.setFixedWidth(260) + self.server_picked() + + # Job list + self.job_list_view = QTableWidget() + self.job_list_view.setRowCount(0) + self.job_list_view.setColumnCount(8) + self.job_list_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.job_list_view.verticalHeader().setVisible(False) + self.job_list_view.itemSelectionChanged.connect(self.job_picked) + self.job_list_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.refresh_job_headers() + + # Image Layout + image_group = QGroupBox("Job Preview") + image_layout = QVBoxLayout(image_group) + image_layout.setContentsMargins(0, 0, 0, 0) + image_center_layout = QHBoxLayout() + image_center_layout.addWidget(self.image_label) + image_layout.addWidget(self.image_label) + # image_layout.addLayout(image_center_layout) + + # Job Layout + job_list_group = QGroupBox("Render Jobs") + job_list_layout = QVBoxLayout(job_list_group) + job_list_layout.setContentsMargins(0, 0, 0, 0) + image_layout.addWidget(self.job_list_view, stretch=True) + image_layout.addLayout(job_list_layout) + + # Add them all to the window + main_layout.addLayout(info_layout) + + right_layout = QVBoxLayout() + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.addWidget(image_group) + # right_layout.addWidget(job_list_group) + main_layout.addLayout(right_layout) + + def __background_update(self): + while True: + self.update_servers() + # self.fetch_jobs() + # todo: fix job updates - issues with threading + time.sleep(0.5) + + def closeEvent(self, event): + running_jobs = len(RenderQueue.running_jobs()) + if running_jobs: + reply = QMessageBox.question(self, "Running Jobs", + f"You have {running_jobs} jobs running.\n" + f"Quitting will cancel these renders. Continue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel) + if reply == QMessageBox.StandardButton.Yes: + event.accept() + else: + event.ignore() + +# -- Server Code -- # + + @property + def current_server_proxy(self): + return self.server_proxies.get(self.current_hostname, None) + + def server_picked(self): + """Update the table and Server Info box when a server is changed""" + try: + new_hostname = self.server_list_view.currentItem().text() + if new_hostname != self.current_hostname: + self.current_hostname = new_hostname + self.job_list_view.setRowCount(0) + self.fetch_jobs(clear_table=True) + + if self.job_list_view.rowCount(): + self.job_list_view.selectRow(0) + + # Update the Server Info box when a server is changed + self.server_info_hostname.setText(self.current_hostname or "unknown") + if self.current_server_proxy.system_os: + self.server_info_os.setText(f"OS: {self.current_server_proxy.system_os} " + f"{self.current_server_proxy.system_os_version}") + self.server_info_cpu.setText(f"CPU: {self.current_server_proxy.system_cpu} - " + f"{self.current_server_proxy.system_cpu_count} cores") + else: + self.server_info_os.setText(f"OS: Loading...") + self.server_info_cpu.setText(f"CPU: Loading...") + + def update_server_info_worker(): + server_details = self.current_server_proxy.get_status() + if server_details['hostname'] == self.current_hostname: + self.server_info_os.setText(f"OS: {server_details.get('system_os')} " + f"{server_details.get('system_os_version')}") + self.server_info_cpu.setText(f"CPU: {server_details.get('system_cpu')} - " + f"{server_details.get('cpu_count')} cores") + + update_thread = threading.Thread(target=update_server_info_worker) + update_thread.start() + except AttributeError: + pass + + def fetch_jobs(self, clear_table=False): + + if not self.current_server_proxy: + return + + if clear_table: + self.job_list_view.clear() + self.refresh_job_headers() + + job_fetch = self.current_server_proxy.get_all_jobs(ignore_token=clear_table) + if job_fetch: + num_jobs = len(job_fetch) + self.job_list_view.setRowCount(num_jobs) + + for row, job in enumerate(job_fetch): + + display_status = job['status'] if job['status'] != RenderStatus.RUNNING.value else \ + ('%.0f%%' % (job['percent_complete'] * 100)) # if running, show percent, otherwise just show status + tags = (job['status'],) + start_time = datetime.datetime.fromisoformat(job['start_time']) if job['start_time'] else None + end_time = datetime.datetime.fromisoformat(job['end_time']) if job['end_time'] else None + + time_elapsed = "" if (job['status'] != RenderStatus.RUNNING.value and not end_time) else \ + get_time_elapsed(start_time, end_time) + + name = job.get('name') or os.path.basename(job.get('input_path', '')) + renderer = f"{job.get('renderer', '')}-{job.get('renderer_version')}" + priority = str(job.get('priority', '')) + total_frames = str(job.get('total_frames', '')) + + items = [QTableWidgetItem(job['id']), QTableWidgetItem(name), QTableWidgetItem(renderer), + QTableWidgetItem(priority), QTableWidgetItem(display_status), QTableWidgetItem(time_elapsed), + QTableWidgetItem(total_frames), QTableWidgetItem(job['date_created'])] + + for col, item in enumerate(items): + self.job_list_view.setItem(row, col, item) + +# -- Job Code -- # + def job_picked(self): + + def fetch_preview(job_id): + try: + before_fetch_hostname = self.current_server_proxy.hostname + response = self.current_server_proxy.request(f'job/{job_id}/thumbnail?size=big') + if response.ok: + import io + image_data = response.content + image = Image.open(io.BytesIO(image_data)) + if self.current_server_proxy.hostname == before_fetch_hostname and job_id == \ + self.selected_job_ids()[0]: + self.load_image_data(image) + except ConnectionError as e: + logger.error(f"Connection error fetching image: {e}") + except Exception as e: + logger.error(f"Error fetching image: {e}") + + job_id = self.selected_job_ids()[0] if self.selected_job_ids() else None + local_server = self.current_hostname == socket.gethostname() + + if job_id: + fetch_thread = threading.Thread(target=fetch_preview, args=(job_id,)) + fetch_thread.daemon = True + fetch_thread.start() + + selected_row = self.job_list_view.selectionModel().selectedRows()[0] + current_status = self.job_list_view.item(selected_row.row(), 4).text() + + # show / hide the stop button + show_stop_button = current_status.lower() == 'running' + self.topbar.actions_call['Stop Job'].setEnabled(show_stop_button) + self.topbar.actions_call['Stop Job'].setVisible(show_stop_button) + self.topbar.actions_call['Delete Job'].setEnabled(not show_stop_button) + self.topbar.actions_call['Delete Job'].setVisible(not show_stop_button) + + self.topbar.actions_call['Render Log'].setEnabled(True) + self.topbar.actions_call['Download'].setEnabled(not local_server) + self.topbar.actions_call['Download'].setVisible(not local_server) + self.topbar.actions_call['Open Files'].setEnabled(local_server) + self.topbar.actions_call['Open Files'].setVisible(local_server) + else: + # load default + default_image_path = os.path.join(resources_dir(), 'Rectangle.png') + self.load_image_path(default_image_path) + self.topbar.actions_call['Stop Job'].setVisible(False) + self.topbar.actions_call['Stop Job'].setEnabled(False) + self.topbar.actions_call['Delete Job'].setEnabled(False) + self.topbar.actions_call['Render Log'].setEnabled(False) + self.topbar.actions_call['Download'].setEnabled(False) + self.topbar.actions_call['Download'].setVisible(True) + self.topbar.actions_call['Open Files'].setEnabled(False) + self.topbar.actions_call['Open Files'].setVisible(False) + + def selected_job_ids(self): + selected_rows = self.job_list_view.selectionModel().selectedRows() + job_ids = [] + for selected_row in selected_rows: + id_item = self.job_list_view.item(selected_row.row(), 0) + job_ids.append(id_item.text()) + return job_ids + + def refresh_job_headers(self): + self.job_list_view.setHorizontalHeaderLabels(["ID", "Name", "Renderer", "Priority", "Status", + "Time Elapsed", "Frames", "Date Created"]) + self.job_list_view.setColumnHidden(0, True) + + self.job_list_view.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self.job_list_view.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) + self.job_list_view.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) + self.job_list_view.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) + self.job_list_view.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) + self.job_list_view.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) + self.job_list_view.horizontalHeader().setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents) + +# -- Image Code -- # + + def load_image_path(self, image_path): + # Load and set the image using QPixmap + pixmap = QPixmap(image_path) + if not pixmap: + logger.error("Error loading image") + return + self.image_label.setPixmap(pixmap) + + def load_image_data(self, pillow_image): + # Convert the Pillow Image to a QByteArray (byte buffer) + byte_array = QByteArray() + buffer = QBuffer(byte_array) + buffer.open(QIODevice.OpenModeFlag.WriteOnly) + pillow_image.save(buffer, "PNG") + buffer.close() + + # Create a QImage from the QByteArray + image = QImage.fromData(byte_array) + + # Create a QPixmap from the QImage + pixmap = QPixmap.fromImage(image) + + if not pixmap: + logger.error("Error loading image") + return + self.image_label.setPixmap(pixmap) + + def update_servers(self): + found_servers = list(set(ZeroconfServer.found_clients() + self.added_hostnames)) + # Always make sure local hostname is first + current_hostname = socket.gethostname() + if found_servers and found_servers[0] != current_hostname: + if current_hostname in found_servers: + found_servers.remove(current_hostname) + found_servers.insert(0, current_hostname) + + old_count = self.server_list_view.count() + + # Update proxys + for hostname in found_servers: + if not self.server_proxies.get(hostname, None): + new_proxy = RenderServerProxy(hostname=hostname) + new_proxy.start_background_update() + self.server_proxies[hostname] = new_proxy + + # Add in all the missing servers + current_server_list = [] + for i in range(self.server_list_view.count()): + current_server_list.append(self.server_list_view.item(i).text()) + for hostname in found_servers: + if hostname not in current_server_list: + image_path = os.path.join(resources_dir(), 'icons', 'Monitor.png') + list_widget = QListWidgetItem(QIcon(image_path), hostname) + self.server_list_view.addItem(list_widget) + + # find any servers that shouldn't be shown any longer + servers_to_remove = [] + for i in range(self.server_list_view.count()): + name = self.server_list_view.item(i).text() + if name not in found_servers: + servers_to_remove.append(name) + + # remove any servers that shouldn't be shown any longer + for server in servers_to_remove: + # Find and remove the item with the specified text + for i in range(self.server_list_view.count()): + item = self.server_list_view.item(i) + if item is not None and item.text() == server: + self.server_list_view.takeItem(i) + break # Stop searching after the first match is found + + if not old_count and self.server_list_view.count(): + self.server_list_view.setCurrentRow(0) + self.server_picked() + + def create_toolbars(self) -> None: + """ + Creates and adds the top and right toolbars to the main window. + """ + # Top Toolbar [PyQt6.QtWidgets.QToolBar] + self.topbar = ToolBar(self, orientation=Qt.Orientation.Horizontal, + style=Qt.ToolButtonStyle.ToolButtonTextUnderIcon, icon_size=(24, 24)) + self.topbar.setMovable(False) + + resources_directory = resources_dir() + + # Top Toolbar Buttons + self.topbar.add_button( + "New Job", f"{resources_directory}/icons/AddProduct.png", self.new_job) + self.topbar.add_button( + "Engines", f"{resources_directory}/icons/SoftwareInstaller.png", self.engine_browser) + self.topbar.add_button( + "Console", f"{resources_directory}/icons/Console.png", self.open_console_window) + self.topbar.add_separator() + self.topbar.add_button( + "Stop Job", f"{resources_directory}/icons/StopSign.png", self.stop_job) + self.topbar.add_button( + "Delete Job", f"{resources_directory}/icons/Trash.png", self.delete_job) + self.topbar.add_button( + "Render Log", f"{resources_directory}/icons/Document.png", self.job_logs) + self.topbar.add_button( + "Download", f"{resources_directory}/icons/Download.png", self.download_files) + self.topbar.add_button( + "Open Files", f"{resources_directory}/icons/SearchFolder.png", self.open_files) + + self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.topbar) + +# -- Toolbar Buttons -- # + + def open_console_window(self) -> None: + """ + Event handler for the "Open Console" button + """ + self.console_window = ConsoleWindow(self.buffer_handler) + self.console_window.buffer_handler = self.buffer_handler + self.console_window.show() + + def engine_browser(self): + self.engine_browser_window = EngineBrowserWindow(hostname=self.current_hostname) + self.engine_browser_window.show() + + def job_logs(self) -> None: + """ + Event handler for the "Logs" button. + """ + selected_job_ids = self.selected_job_ids() + if selected_job_ids: + url = f'http://{self.current_server_proxy.hostname}:{self.current_server_proxy.port}/api/job/{selected_job_ids[0]}/logs' + self.log_viewer_window = LogViewer(url) + self.log_viewer_window.show() + + def stop_job(self, event): + """ + Event handler for the "Exit" button. Closes the application. + """ + job_ids = self.selected_job_ids() + if not job_ids: + return + + if len(job_ids) == 1: + job = next((job for job in self.current_server_proxy.get_all_jobs() if job.get('id') == job_ids[0]), None) + if job: + display_name = job.get('name', os.path.basename(job.get('input_path', ''))) + message = f"Are you sure you want to delete the job:\n{display_name}?" + else: + return # Job not found, handle this case as needed + else: + message = f"Are you sure you want to delete these {len(job_ids)} jobs?" + + # Display the message box and check the response in one go + msg_box = QMessageBox(QMessageBox.Icon.Warning, "Delete Job", message, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, self) + + if msg_box.exec() == QMessageBox.StandardButton.Yes: + for job_id in job_ids: + self.current_server_proxy.cancel_job(job_id, confirm=True) + self.fetch_jobs(clear_table=True) + + def delete_job(self, event): + """ + Event handler for the Delete Job button + """ + job_ids = self.selected_job_ids() + if not job_ids: + return + + if len(job_ids) == 1: + job = next((job for job in self.current_server_proxy.get_all_jobs() if job.get('id') == job_ids[0]), None) + if job: + display_name = job.get('name', os.path.basename(job.get('input_path', ''))) + message = f"Are you sure you want to delete the job:\n{display_name}?" + else: + return # Job not found, handle this case as needed + else: + message = f"Are you sure you want to delete these {len(job_ids)} jobs?" + + # Display the message box and check the response in one go + msg_box = QMessageBox(QMessageBox.Icon.Warning, "Delete Job", message, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, self) + + if msg_box.exec() == QMessageBox.StandardButton.Yes: + for job_id in job_ids: + self.current_server_proxy.delete_job(job_id, confirm=True) + self.fetch_jobs(clear_table=True) + + def download_files(self, event): + pass + + def open_files(self, event): + job_ids = self.selected_job_ids() + if not job_ids: + return + + for job_id in job_ids: + job_info = self.current_server_proxy.get_job_info(job_id) + path = os.path.dirname(job_info['output_path']) + + if sys.platform.startswith('darwin'): + subprocess.run(['open', path]) + elif sys.platform.startswith('win32'): + os.startfile(path) + elif sys.platform.startswith('linux'): + subprocess.run(['xdg-open', path]) + else: + raise OSError("Unsupported operating system") + + def new_job(self) -> None: + self.new_job_window = NewRenderJobForm() + self.new_job_window.show() diff --git a/src/ui/widgets/__init__.py b/src/ui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/widgets/dialog.py b/src/ui/widgets/dialog.py new file mode 100644 index 0000000..ad23363 --- /dev/null +++ b/src/ui/widgets/dialog.py @@ -0,0 +1 @@ +''' app/ui/widgets/dialog.py ''' diff --git a/src/ui/widgets/menubar.py b/src/ui/widgets/menubar.py new file mode 100644 index 0000000..0cdb6cc --- /dev/null +++ b/src/ui/widgets/menubar.py @@ -0,0 +1,23 @@ +''' app/ui/widgets/menubar.py ''' +from PyQt6.QtWidgets import QMenuBar + + +class MenuBar(QMenuBar): + """ + Initialize the menu bar. + + Args: + parent: The parent widget. + """ + + def __init__(self, parent=None) -> None: + super().__init__(parent) + file_menu = self.addMenu("File") + # edit_menu = self.addMenu("Edit") + # view_menu = self.addMenu("View") + # help_menu = self.addMenu("Help") + + # Add actions to the menus + # file_menu.addAction(self.parent().topbar.actions_call["Open"]) # type: ignore + # file_menu.addAction(self.parent().topbar.actions_call["Save"]) # type: ignore + # file_menu.addAction(self.parent().topbar.actions_call["Exit"]) # type: ignore diff --git a/src/ui/widgets/proportional_image_label.py b/src/ui/widgets/proportional_image_label.py new file mode 100644 index 0000000..1188d60 --- /dev/null +++ b/src/ui/widgets/proportional_image_label.py @@ -0,0 +1,40 @@ +from PyQt6.QtCore import QRectF +from PyQt6.QtGui import QPainter +from PyQt6.QtWidgets import QLabel + + +class ProportionalImageLabel(QLabel): + def __init__(self): + super().__init__() + + def setPixmap(self, pixmap): + self._pixmap = pixmap + super().setPixmap(self._pixmap) + + def paintEvent(self, event): + if self._pixmap.isNull(): + super().paintEvent(event) + return + + painter = QPainter(self) + targetRect = event.rect() + + # Calculate the aspect ratio of the pixmap + aspectRatio = self._pixmap.width() / self._pixmap.height() + + # Calculate the size of the pixmap within the target rectangle while maintaining the aspect ratio + if aspectRatio > targetRect.width() / targetRect.height(): + scaledWidth = targetRect.width() + scaledHeight = targetRect.width() / aspectRatio + else: + scaledHeight = targetRect.height() + scaledWidth = targetRect.height() * aspectRatio + + # Calculate the position to center the pixmap within the target rectangle + x = targetRect.x() + (targetRect.width() - scaledWidth) / 2 + y = targetRect.y() + (targetRect.height() - scaledHeight) / 2 + + sourceRect = QRectF(0.0, 0.0, self._pixmap.width(), self._pixmap.height()) + targetRect = QRectF(x, y, scaledWidth, scaledHeight) + + painter.drawPixmap(targetRect, self._pixmap, sourceRect) \ No newline at end of file diff --git a/src/ui/widgets/statusbar.py b/src/ui/widgets/statusbar.py new file mode 100644 index 0000000..6561f6e --- /dev/null +++ b/src/ui/widgets/statusbar.py @@ -0,0 +1,61 @@ +''' app/ui/widgets/statusbar.py ''' +import os.path +import socket +import threading +import time + +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QPixmap +from PyQt6.QtWidgets import QStatusBar, QLabel + +from src.api.server_proxy import RenderServerProxy +from src.utilities.misc_helper import resources_dir + + +class StatusBar(QStatusBar): + """ + Initialize the status bar. + + Args: + parent: The parent widget. + """ + + def __init__(self, parent) -> None: + super().__init__(parent) + + def background_update(): + + proxy = RenderServerProxy(socket.gethostname()) + proxy.start_background_update() + image_names = {'Ready': 'GreenCircle.png', 'Offline': "RedSquare.png"} + last_update = None + + # Check for status change every 1s on background thread + while True: + new_status = proxy.status() + if new_status is not last_update: + new_image_name = image_names.get(new_status, 'Synchronize.png') + image_path = os.path.join(resources_dir(), 'icons', new_image_name) + self.label.setPixmap((QPixmap(image_path).scaled(16, 16, Qt.AspectRatioMode.KeepAspectRatio))) + self.messageLabel.setText(new_status) + last_update = new_status + time.sleep(1) + + background_thread = threading.Thread(target=background_update,) + background_thread.daemon = True + background_thread.start() + + # Create a label that holds an image + self.label = QLabel() + image_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'resources', 'icons', + 'RedSquare.png') + pixmap = (QPixmap(image_path).scaled(16, 16, Qt.AspectRatioMode.KeepAspectRatio)) + self.label.setPixmap(pixmap) + self.addWidget(self.label) + + # Create a label for the message + self.messageLabel = QLabel() + self.addWidget(self.messageLabel) + + # Call this method to display a message + self.messageLabel.setText("Loading...") diff --git a/src/ui/widgets/toolbar.py b/src/ui/widgets/toolbar.py new file mode 100644 index 0000000..1960db1 --- /dev/null +++ b/src/ui/widgets/toolbar.py @@ -0,0 +1,49 @@ +''' app/ui/widgets/toolbar.py ''' +from PyQt6.QtCore import Qt, QSize +from PyQt6.QtGui import QAction, QIcon +from PyQt6.QtWidgets import QToolBar, QWidget, QSizePolicy + + +class ToolBar(QToolBar): + """ + Initialize the toolbar. + + Args: + parent: The parent widget. + orientation: The toolbar's orientation. + style: The toolbar's tool button style. + icon_size: The toolbar's icon size. + """ + + def __init__(self, parent, + orientation: Qt.Orientation = Qt.Orientation.Horizontal, + style: Qt.ToolButtonStyle = Qt.ToolButtonStyle.ToolButtonTextUnderIcon, + icon_size: tuple[int, int] = (32, 32)) -> None: + super().__init__(parent) + self.actions_call = {} + self.setOrientation(orientation) + + self.setToolButtonStyle(style) + self.setIconSize(QSize(icon_size[0], icon_size[1])) + + def add_button(self, text: str, icon: str, trigger_action) -> None: + """ + Add a button to the toolbar. + + Args: + text: The button's text. + icon: The button's icon. + trigger_action: The action to be executed when the button is clicked. + """ + self.actions_call[text] = QAction(QIcon(icon), text, self) + self.actions_call[text].triggered.connect(trigger_action) + self.addAction(self.actions_call[text]) + + def add_separator(self) -> None: + """ + Add a separator to the toolbar. + """ + separator = QWidget(self) + separator.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.addWidget(separator) diff --git a/src/ui/widgets/treeview.py b/src/ui/widgets/treeview.py new file mode 100644 index 0000000..4c0febb --- /dev/null +++ b/src/ui/widgets/treeview.py @@ -0,0 +1,29 @@ +''' app/ui/widgets/treeview.py ''' +from PyQt6.QtWidgets import QTreeView +from PyQt6.QtGui import QFileSystemModel +from PyQt6.QtCore import QDir + + +class TreeView(QTreeView): + """ + Initialize the TreeView widget. + + Args: + parent (QWidget, optional): Parent widget of the TreeView. Defaults to None. + """ + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.file_system_model: QFileSystemModel = QFileSystemModel() + self.file_system_model.setRootPath(QDir.currentPath()) + self.setModel(self.file_system_model) + self.setRootIndex(self.file_system_model.index(QDir.currentPath())) + self.setColumnWidth(0, 100) + self.setFixedWidth(150) + self.setSortingEnabled(True) + + def clear_view(self) -> None: + """ + Clearing the TreeView + """ + self.destroy(destroySubWindows=True) diff --git a/src/utilities/misc_helper.py b/src/utilities/misc_helper.py index 8e42da9..d809a79 100644 --- a/src/utilities/misc_helper.py +++ b/src/utilities/misc_helper.py @@ -123,3 +123,15 @@ def current_system_os_version(): def current_system_cpu(): # convert all x86 64 to "x64" return platform.machine().lower().replace('amd64', 'x64').replace('x86_64', 'x64') + + +def resources_dir(): + resources_directory = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + 'resources') + return resources_directory + + +def config_dir(): + config_directory = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + 'config') + return config_directory diff --git a/src/utilities/zeroconf_server.py b/src/utilities/zeroconf_server.py index a3f74ac..3686ce0 100644 --- a/src/utilities/zeroconf_server.py +++ b/src/utilities/zeroconf_server.py @@ -35,19 +35,22 @@ class ZeroconfServer: @classmethod def _register_service(cls): - cls.server_ip = socket.gethostbyname(socket.gethostname()) + try: + cls.server_ip = socket.gethostbyname(socket.gethostname()) - info = ServiceInfo( - cls.service_type, - f"{cls.server_name}.{cls.service_type}", - addresses=[socket.inet_aton(cls.server_ip)], - port=cls.server_port, - properties=cls.properties, - ) + info = ServiceInfo( + cls.service_type, + f"{cls.server_name}.{cls.service_type}", + addresses=[socket.inet_aton(cls.server_ip)], + port=cls.server_port, + properties=cls.properties, + ) - cls.service_info = info - cls.zeroconf.register_service(info) - logger.info(f"Registered zeroconf service: {cls.service_info.name}") + cls.service_info = info + cls.zeroconf.register_service(info) + logger.info(f"Registered zeroconf service: {cls.service_info.name}") + except socket.gaierror as e: + logger.error(f"Error starting zeroconf service: {e}") @classmethod def _unregister_service(cls): @@ -73,7 +76,17 @@ class ZeroconfServer: @classmethod def found_clients(cls): - return [x.split(f'.{cls.service_type}')[0] for x in cls.client_cache.keys()] + + fetched_hostnames = [x.split(f'.{cls.service_type}')[0] for x in cls.client_cache.keys()] + local_hostname = socket.gethostname() + # Define a sort key function + def sort_key(hostname): + # Return 0 if it's the local hostname so it comes first, else return 1 + return 0 if hostname == local_hostname else 1 + + # Sort the list with the local hostname first + sorted_hostnames = sorted(fetched_hostnames, key=sort_key) + return sorted_hostnames # Example usage: