diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6273b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*/__pycache__/* +*.DS_Store +build/out \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..16a9343 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Riot Games, Inc + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..09790a1 --- /dev/null +++ b/Pipfile @@ -0,0 +1,6 @@ +[packages] +pyside2 = "==5.12.0" +psutil = "==5.5.0" + +[requires] +python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..96a828c --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,59 @@ +{ + "_meta": { + "hash": { + "sha256": "00f7e985c94bf1ca13e2a1c15c40d2f690675fc5374851a14101a105209a6b12" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.7" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "psutil": { + "hashes": [ + "sha256:04d2071100aaad59f9bcbb801be2125d53b2e03b1517d9fed90b45eea51d297e", + "sha256:1aba93430050270750d046a179c5f3d6e1f5f8b96c20399ba38c596b28fc4d37", + "sha256:3ac48568f5b85fee44cd8002a15a7733deca056a191d313dbf24c11519c0c4a8", + "sha256:96f3fdb4ef7467854d46ad5a7e28eb4c6dc6d455d751ddf9640cd6d52bdb03d7", + "sha256:b755be689d6fc8ebc401e1d5ce5bac867e35788f10229e166338484eead51b12", + "sha256:c8ee08ad1b716911c86f12dc753eb1879006224fd51509f077987bb6493be615", + "sha256:d0c4230d60376aee0757d934020b14899f6020cd70ef8d2cb4f228b6ffc43e8f", + "sha256:d23f7025bac9b3e38adc6bd032cdaac648ac0074d18e36950a04af35458342e8", + "sha256:f0fcb7d3006dd4d9ccf3ccd0595d44c6abbfd433ec31b6ca177300ee3f19e54e" + ], + "index": "pypi", + "version": "==5.5.0" + }, + "pyside2": { + "hashes": [ + "sha256:28c84514f4da903d00c59026c4d932b18ba5346b77959e5b23a36e68ed116e07", + "sha256:6630b1580fce924b953c8a8bed2e18a74d52b82cc8850ffdb9265b3977ac1c3d", + "sha256:764f610693829fa315eaea1e0316faf138f43b9501f7fa1826e99be344f84f25", + "sha256:7f8064c9e443d9fa817e1d939c67c698d5039c5154d06df2bc5bea1ee08614ae", + "sha256:aca1358217be52d003f1268c767a06ffe574a1715960ba6a10beda5c39acd34e", + "sha256:e5fcd43289f43b0baaf2ff3629fea01e669b0f7e01264222ae963bda8e3e42f7" + ], + "index": "pypi", + "version": "==5.12.0" + }, + "shiboken2": { + "hashes": [ + "sha256:0a70a0b9960c1cea426dae1ed1082ac2d0ff2f7f46862aef190f0f5067fdaf93", + "sha256:1985ebb922897bf6344e2f138a758d6190366a5426d9a45fb68465bcdebba4eb", + "sha256:386939d8357d5d14338e7ab3f574060fb5044a4044f2cf718b6fbd23ce3887e1", + "sha256:63ab17e3bf12351b26705c9dc9653d63d714d404fe2975b54cbb08846328e7e3", + "sha256:9e5d248064eca947828e36d608d80922b1fe00445587ad42b3a532d8502a17fb", + "sha256:a8a377fe251e4ba81ca33f2e227ac6afe1189512311c8823a133b212c12bc294" + ], + "version": "==5.12.0" + } + }, + "develop": {} +} diff --git a/README.md b/README.md index 2c142e5..9a0a9f3 100644 --- a/README.md +++ b/README.md @@ -1 +1,77 @@ -# leaguedirector \ No newline at end of file +## League Director +[![License](https://img.shields.io/badge/license-Apache%202-blue.svg)](https://github.com/riotgames/leaguedirector/blob/master/LICENSE) +[![Python](https://img.shields.io/badge/python-3.7-brightgreen.svg)](https://www.python.org/downloads/release/python-372/) +[![Qt](https://img.shields.io/badge/pyside2-5.12.0-brightgreen.svg)](https://www.qt.io/qt-for-python) +[![Chat](https://img.shields.io/badge/chat-on%20discord-lightgrey.svg)](https://discord.gg/010zxwmnpLTuQY6sG) + +League Director is a tool for staging and recording videos from [League of Legends](https://leagueoflegends.com) replays. + +![Screenshot](resources/screenshot.png) + +## Features + +* Control replay playback and speed +* First person camera controls +* Attach camera to champion or minion +* Toggle interface elements including HUD, health bars and notifications +* Graphical Options + - Field of view + - Near and far clipping + - Custom skyboxes + - Shadow direction + - Depth and height fog + - Depth of field +* Sequencer + - Record and playback keyframed camera position + graphical options + - Timeline for viewing and editing keyframe values + - Undo / Redo + - Save and load pre saved sequences + - Adjustable keyframe blending +* Video capture in webm or png format +* Customizable key bindings + +## How To Use + +**Note: Windows Only** + +1. Download the latest version from the releases page and install. +2. Start League Director and make sure the checkbox next to your install is checked. +3. Start League of Legends and launch a replay. League Director will automatically connect. +4. Select FPS Camera from the Camera Modes **inside the replay client**. +5. Using the numpad keys (4, 5, 6, 8) and the mouse you can free camera move around. Key bindings for free camera can be changed inside the game options. + +## Tutorial + +[![](http://img.youtube.com/vi/UOxkGD8qRB4/0.jpg)](http://www.youtube.com/watch?v=UOxkGD8qRB4 "League Director Tutorial") + +## Frequently Asked Questions +TODO + +## Developing +To run the source version of this application you need the [latest 3.7.2 version](https://www.python.org/downloads/release/python-372/) of Python installed. From the windows command line: + +``` +# Clone this repository +$ git clone https://github.com/riotgames/leaguedirector.git + +# Change directory +$ cd leaguedirector + +# Run the startup script +$ run.bat +``` + +The run batch file will setup a virtual environment using [Pipenv](https://pipenv.readthedocs.io/en/latest/) and install required dependencies such as [Qt](https://www.qt.io/qt-for-python). + +_League Director is being release by Riot Games as a reference implementation for the [Replay API](https://developer.riotgames.com/replay-apis.html). You are free to download and modify this source code or create your own fork of the project but we will not be accepting pull requests at this time._ + +## License +Apache 2 (see [LICENSE](https://github.com/riotgames/leaguedirector/blob/master/LICENSE) for details) + +For usage rights of Riot Games intellectual property, such as the skybox textures bundled with this installer, please refer to: + +[https://www.riotgames.com/en/legal](https://www.riotgames.com/en/legal) + +## Special Thanks + * Skin Spotlights + * League of Editing diff --git a/build/build.bat b/build/build.bat new file mode 100644 index 0000000..a3018df --- /dev/null +++ b/build/build.bat @@ -0,0 +1,5 @@ +@echo off +cd %~dp0 +pipenv run pip install pyinstaller==3.4 +pipenv run pyinstaller build.spec --noconfirm --workpath=out/build --distpath=out/dist +ISCC.exe install.iss diff --git a/build/build.spec b/build/build.spec new file mode 100644 index 0000000..a0c8e9d --- /dev/null +++ b/build/build.spec @@ -0,0 +1,31 @@ +import sys +sys.modules['FixTk'] = None + +a = Analysis(['..\\leaguedirector\\app.py'], + binaries = [], + datas = [], + hiddenimports = [], + hookspath = [], + runtime_hooks = [], + excludes = ['FixTk', 'tcl', 'tk', '_tkinter', 'tkinter', 'Tkinter', 'lib2to3'], + win_no_prefer_redirects = False, + win_private_assemblies = False, + cipher = None, + noarchive = False +) +pyz = PYZ(a.pure, a.zipped_data, cipher=None) +exe = EXE(pyz, a.scripts, [], + exclude_binaries = True, + name = 'LeagueDirector', + debug = False, + bootloader_ignore_signals = False, + strip = False, + upx = True, + console = False, + icon = '..\\resources\\icon.ico' +) +coll = COLLECT(exe, a.binaries, a.zipfiles, a.datas, + strip = False, + upx = True, + name = 'LeagueDirector' +) diff --git a/build/install.iss b/build/install.iss new file mode 100644 index 0000000..a7bf35f --- /dev/null +++ b/build/install.iss @@ -0,0 +1,23 @@ +[Setup] +AppName=League Director +AppVersion=0.1 +AppVerName=League Director (0.1) +DefaultDirName={pf}\League Director +DefaultGroupName=League Director +UninstallDisplayIcon={app}\LeagueDirector.exe +Compression=lzma2 +SolidCompression=yes +OutputDir=out +OutputBaseFilename=LeagueDirectorSetup +SetupIconFile=..\resources\icon.ico +LicenseFile=..\LICENSE + +[Files] +Source: "out\dist\LeagueDirector\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs +Source: "..\resources\*"; DestDir: "{app}\resources\"; Flags: ignoreversion recursesubdirs + +[Icons] +Name: "{group}\League Director"; Filename: "{app}\LeagueDirector.exe" + +[Run] +Filename: "{app}\LeagueDirector.exe"; Description: "Launch League Directory"; Flags: postinstall nowait skipifsilent diff --git a/leaguedirector/__init__.py b/leaguedirector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leaguedirector/api.py b/leaguedirector/api.py new file mode 100644 index 0000000..7adf866 --- /dev/null +++ b/leaguedirector/api.py @@ -0,0 +1,636 @@ +import os +import time +import json +import copy +import functools +from leaguedirector.widgets import userpath +from PySide2.QtCore import * +from PySide2.QtNetwork import * + + +class Resource(QObject): + """ + Base class for a remote api resources. + """ + updated = Signal() + host = 'https://127.0.0.1:2999' + url = '' + fields = {} + connected = False + readonly = False + writeonly = False + network = None + + def __init__(self): + object.__setattr__(self, 'timestamp', time.time()) + for name, default in self.fields.items(): + object.__setattr__(self, name, default) + QObject.__init__(self) + + def __setattr__(self, name, value): + if name in self.fields: + if self.readonly: + raise AttributeError("Resource is readonly") + if getattr(self, name) != value: + object.__setattr__(self, name, value) + self.update({name: value}) + else: + object.__setattr__(self, name, value) + + def sslErrors(self, response, errors): + response.ignoreSslErrors([e for e in errors if e.error() == QSslError.HostNameMismatch]) + + def manager(self): + if Resource.network is None: + # QT does not ship SSL binaries so we have to bundle them in our res directory + os.environ['PATH'] = os.path.abspath('resources') + os.pathsep + os.environ['PATH'] + + # Then setup our certificate for the lol game client + QSslSocket.addDefaultCaCertificates(os.path.abspath('resources/riotgames.pem')) + Resource.network = QNetworkAccessManager(QCoreApplication.instance()) + Resource.network.sslErrors.connect(self.sslErrors) + return Resource.network + + def set(self, name, value): + self.__setattr__(name, value) + + def get(self, name): + return getattr(self, name) + + def shutdown(self): + pass + + def data(self): + return {name: getattr(self, name) for name in self.fields} + + def keys(self): + return self.fields.keys() + + def update(self, data=None): + request = QNetworkRequest(QUrl(self.host + self.url)) + if data is not None: + request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") + response = self.manager().post(request, QByteArray(json.dumps(data).encode())) + else: + response = self.manager().get(request) + response.finished.connect(functools.partial(self.finished, response)) + + def finished(self, response): + error = response.error() + if error == QNetworkReply.NoError: + Resource.connected = True + self.apply(json.loads(response.readAll().data().decode())) + self.timestamp = time.time() + elif error in (QNetworkReply.ConnectionRefusedError, QNetworkReply.TimeoutError): + Resource.connected = False + else: + print(self.url, response.errorString()) + self.updated.emit() + + def apply(self, data): + if not self.writeonly: + for key, value in data.items(): + if key in self.fields: + object.__setattr__(self, key, value) + + +class Game(Resource): + url = '/replay/game' + fields = {'processID': 0} + readonly = True + + +class Recording(Resource): + url = '/replay/recording' + fields = { + 'recording': False, + 'path': '', + 'codec': '', + 'startTime': 0, + 'endTime': 0, + 'currentTime': 0, + 'width': 0, + 'height': 0, + 'framesPerSecond': 0, + 'enforceFrameRate': False, + 'replaySpeed': 0, + } + + +class Render(Resource): + url = '/replay/render' + fields = { + 'cameraMode' : '', + 'cameraPosition' : {'x': 0, 'y': 0, 'z': 0}, + 'cameraRotation' : {'x': 0, 'y': 0, 'z': 0}, + 'cameraAttached' : False, + 'cameraMoveSpeed' : 0, + 'cameraLookSpeed' : 0, + 'fieldOfView' : 0, + 'nearClip' : 0, + 'farClip' : 0, + 'fogOfWar' : True, + 'outlineSelect' : True, + 'outlineHover' : True, + 'floatingText' : True, + 'navGridOffset' : 0, + 'interfaceAll' : True, + 'interfaceReplay' : True, + 'interfaceScore' : True, + 'interfaceScoreboard' : True, + 'interfaceFrames' : True, + 'interfaceMinimap' : True, + 'interfaceTimeline' : True, + 'interfaceChat' : True, + 'interfaceTarget' : True, + 'interfaceQuests' : True, + 'interfaceAnnounce' : True, + 'healthBarChampions' : True, + 'healthBarStructures' : True, + 'healthBarWards' : True, + 'healthBarPets' : True, + 'healthBarMinions' : True, + 'environment' : True, + 'characters' : True, + 'particles' : True, + 'skyboxPath' : '', + 'skyboxRotation' : 0, + 'skyboxRadius' : 0, + 'skyboxOffset' : 0, + 'sunDirection' : {'x': 0, 'y': 0, 'z': 0}, + 'depthFogEnabled' : False, + 'depthFogStart' : 0, + 'depthFogEnd' : 0, + 'depthFogIntensity' : 1, + 'depthFogColor' : {'r': 0, 'g': 0, 'b': 0, 'a': 0}, + 'heightFogEnabled' : False, + 'heightFogStart' : 0, + 'heightFogEnd' : 0, + 'heightFogIntensity' : 1, + 'heightFogColor' : {'r': 0, 'g': 0, 'b': 0, 'a': 0}, + 'depthOfFieldEnabled' : False, + 'depthOfFieldDebug' : False, + 'depthOfFieldCircle' : 0, + 'depthOfFieldWidth' : 0, + 'depthOfFieldNear' : 0, + 'depthOfFieldMid' : 0, + 'depthOfFieldFar' : 0, + } + + def __init__(self): + Resource.__init__(self) + self.cameraLockX = None + self.cameraLockY = None + self.cameraLockZ = None + self.cameraLockLast = None + self.timer = QTimer() + self.timer.timeout.connect(self.updateCameraLock) + self.timer.start(600) + + def updateCameraLock(self, *args): + # Wait until the camera stops moving before snapping it + if self.cameraLockLast != self.cameraPosition: + self.cameraLockLast = self.cameraPosition + else: + copy = dict(self.cameraPosition) + if self.cameraLockX is not None: + copy['x'] = self.cameraLockX + if self.cameraLockY is not None: + copy['y'] = self.cameraLockY + if self.cameraLockZ is not None: + copy['z'] = self.cameraLockZ + self.cameraPosition = copy + + def toggleCameraLockX(self): + self.cameraLockX = self.cameraPosition['x'] if self.cameraLockX is None else None + + def toggleCameraLockY(self): + self.cameraLockY = self.cameraPosition['y'] if self.cameraLockY is None else None + + def toggleCameraLockZ(self): + self.cameraLockZ = self.cameraPosition['z'] if self.cameraLockZ is None else None + + def moveCamera(self, x=0, y=0, z=0): + copy = dict(self.cameraPosition) + copy['x'] += x + copy['y'] += y + copy['z'] += z + self.cameraPosition = copy + + def rotateCamera(self, x=0, y=0, z=0): + copy = dict(self.cameraRotation) + copy['x'] += x + copy['y'] += y + copy['z'] += z + self.cameraRotation = copy + + +class Playback(Resource): + url = '/replay/playback' + fields = { + 'paused': False, + 'seeking': False, + 'time': 0.0, + 'speed': 0.0, + 'length': 1.0, + } + + @property + def currentTime(self): + if self.paused: + return self.time + else: + return min(self.time + (time.time() - self.timestamp) * self.speed, self.length) + + @property + def currentTimeFormatted(self): + minutes, seconds = divmod(self.currentTime, 60) + return '{0:02}:{1:05.2f}'.format(int(minutes), seconds) + + def togglePlay(self): + self.paused = not self.paused + + def setSpeed(self, speed): + self.speed = speed + + def adjustTime(self, delta): + self.time = self.currentTime + delta + + def play(self, time=None): + if not self.seeking: + data = {'paused': False} + if time is not None: + data['time'] = time + self.update(data) + + def pause(self, time=None): + if not self.seeking: + data = {'paused': True} + if time is not None: + data['time'] = time + self.update(data) + + +class Sequence(Resource): + dataLoaded = Signal() + namesLoaded = Signal() + url = '/replay/sequence' + writeonly = True + history = [] + history_index = 0 + fields = { + 'playbackSpeed': [], + 'cameraPosition': [], + 'cameraRotation': [], + 'fieldOfView': [], + 'nearClip': [], + 'farClip': [], + 'navGridOffset': [], + 'skyboxRotation': [], + 'skyboxRadius': [], + 'skyboxOffset': [], + 'sunDirection': [], + 'depthFogEnabled': [], + 'depthFogStart': [], + 'depthFogEnd': [], + 'depthFogIntensity': [], + 'depthFogColor': [], + 'heightFogEnabled': [], + 'heightFogStart': [], + 'heightFogEnd': [], + 'heightFogIntensity': [], + 'heightFogColor': [], + 'depthOfFieldEnabled': [], + 'depthOfFieldCircle': [], + 'depthOfFieldWidth': [], + 'depthOfFieldNear': [], + 'depthOfFieldMid': [], + 'depthOfFieldFar': [], + } + blendOptions = [ + 'linear', + 'snap', + 'smoothStep', + 'smootherStep', + 'quadraticEaseIn', + 'quadraticEaseOut', + 'quadraticEaseInOut', + 'cubicEaseIn', + 'cubicEaseOut', + 'cubicEaseInOut', + 'quarticEaseIn', + 'quarticEaseOut', + 'quarticEaseInOut', + 'quinticEaseIn', + 'quinticEaseOut', + 'quinticEaseInOut', + 'sineEaseIn', + 'sineEaseOut', + 'sineEaseInOut', + 'circularEaseIn', + 'circularEaseOut', + 'circularEaseInOut', + 'exponentialEaseIn', + 'exponentialEaseOut', + 'exponentialEaseInOut', + 'elasticEaseIn', + 'elasticEaseOut', + 'elasticEaseInOut', + 'backEaseIn', + 'backEaseOut', + 'backEaseInOut', + 'bounceEaseIn', + 'bounceEaseOut', + 'bounceEaseInOut', + ] + + def __init__(self, render, playback): + Resource.__init__(self) + self.render = render + self.playback = playback + self.name = '' + self.names = [] + self.sequencing = False + self.saveRemoteTimer = QTimer() + self.saveRemoteTimer.timeout.connect(self.saveRemoteNow) + self.saveRemoteTimer.setSingleShot(True) + self.saveHistoryTimer = QTimer() + self.saveHistoryTimer.timeout.connect(self.saveHistoryNow) + self.saveHistoryTimer.setSingleShot(True) + self.saveFileTimer = QTimer() + self.saveFileTimer.timeout.connect(self.saveFileNow) + self.saveFileTimer.setSingleShot(True) + + def update(self, *args): + self.saveRemote() + self.saveFile() + self.saveHistory() + + def data(self): + return {key:getattr(self, key) for key in self.fields} + + @property + def startTime(self): + keyframes = self.cameraPosition + self.cameraRotation + if len(keyframes): + return min(keyframe['time'] for keyframe in keyframes) + + @property + def endTime(self): + keyframes = self.cameraPosition + self.cameraRotation + if len(keyframes): + return max(keyframe['time'] for keyframe in keyframes) + + def path(self): + return os.path.join(self.directory, self.name + '.json') + + def load(self, name): + self.saveFileNow() + self.loadFile(name) + + def create(self, name): + self.saveFileNow() + self.clearData() + self.resetHistory() + self.saveFileNow(name) + self.reloadNames() + + def save(self, name=None): + self.saveFile(name) + + def copy(self, name): + oldName = self.name + self.saveFileNow(name) + self.saveFileNow(oldName) + self.reloadNames() + + def undo(self): + self.loadHistory(self.history_index - 1) + + def redo(self): + self.loadHistory(self.history_index + 1) + + def setDirectory(self, path): + if os.path.exists(path) and os.path.isdir(path): + self.directory = path + self.clearData() + self.loadFile('default') + self.saveFileNow() + self.reloadNames() + + def saveRemoteNow(self): + self.sortData() + if self.sequencing: + Resource.update(self, self.data()) + else: + Resource.update(self, {}) + + def saveRemote(self): + self.saveRemoteTimer.start(0) + + def saveHistoryNow(self): + self.history = self.history[0:self.history_index + 1] + self.history_index = len(self.history) + self.history.append(copy.deepcopy(self.data())) + + def saveHistory(self): + self.saveHistoryTimer.start(500) + + def loadHistory(self, index): + if len(self.history): + self.history_index = max(min(index, len(self.history) - 1), 0) + self.loadData(copy.deepcopy(self.history[self.history_index])) + self.saveRemote() + self.saveFileNow() + + def resetHistory(self): + self.history = [] + self.history_index = 0 + + def loadFile(self, name): + self.name = name + if os.path.exists(self.path()): + with open(self.path(), 'r') as f: + self.resetHistory() + self.loadData(json.load(f)) + self.saveRemote() + self.saveHistory() + + def saveFileNow(self, name=None): + self.name = name or self.name + if self.name: + path = self.path() + exists = os.path.exists(path) + with open(path, 'w') as f: + json.dump(self.data(), f, sort_keys=True, indent=4) + if not exists: + self.reloadNames() + + def saveFile(self, name=None): + self.name = name or self.name + self.saveFileTimer.start(1000) + + def clearData(self): + for track in self.fields: + getattr(self, track, []).clear() + self.dataLoaded.emit() + + def loadData(self, data): + if isinstance(data, dict): + for key, value in data.items(): + if value is not None: + object.__setattr__(self, key, value) + self.dataLoaded.emit() + + def sortData(self): + for track in self.fields: + if getattr(self, track): + getattr(self, track).sort(key = lambda item: item['time']) + + def reloadNames(self): + self.names = sorted([f.replace('.json', '') for f in os.listdir(self.directory) if f.endswith('.json')], key=str.lower) + self.namesLoaded.emit() + + @property + def index(self): + try: + return self.names.index(self.name) + except ValueError: + return 0 + + def setSequencing(self, value): + self.sequencing = value + self.update() + + def getKeyframes(self, name): + return getattr(self, name) + + def createKeyframe(self, name): + keyframe = { + 'time': self.playback.time, + 'value': self.getValue(name), + 'blend': 'linear', + } + self.appendKeyframe(name, keyframe) + return keyframe + + def appendKeyframe(self, name, keyframe): + getattr(self, name).append(keyframe) + self.update() + + def removeKeyframe(self, name, item): + getattr(self, name).remove(item) + self.update() + + def getLabel(self, name): + if name == 'cameraPosition': + return 'Camera Position' + if name == 'cameraRotation': + return 'Camera Rotation' + if name == 'playbackSpeed': + return 'Playback Speed' + if name == 'fieldOfView': + return 'Field Of View' + if name == 'nearClip': + return 'Near Clip' + if name == 'farClip': + return 'Far Clip' + if name == 'navGridOffset': + return 'Nav Grid Offset' + if name == 'skyboxRotation': + return 'Skybox Rotation' + if name == 'skyboxRadius': + return 'Skybox Radius' + if name == 'skyboxOffset': + return 'Skybox Offset' + if name == 'sunDirection': + return 'Sun Direciton' + if name == 'depthFogEnabled': + return 'Depth Fog Enable' + if name == 'depthFogStart': + return 'Depth Fog Start' + if name == 'depthFogEnd': + return 'Depth Fog End' + if name == 'depthFogIntensity': + return 'Depth Fog Intensity' + if name == 'depthFogColor': + return 'Depth Fog Color' + if name == 'heightFogEnabled': + return 'Height Fog Enabled' + if name == 'heightFogStart': + return 'Height Fog Start' + if name == 'heightFogEnd': + return 'Height Fog End' + if name == 'heightFogIntensity': + return 'Height Fog Intensity' + if name == 'heightFogColor': + return 'Height Fog Color' + if name == 'depthOfFieldEnabled': + return 'DOF Enabled' + if name == 'depthOfFieldCircle': + return 'DOF Circle' + if name == 'depthOfFieldWidth': + return 'DOF Width' + if name == 'depthOfFieldNear': + return 'DOF Near' + if name == 'depthOfFieldMid': + return 'DOF Mid' + if name == 'depthOfFieldFar': + return 'DOF Far' + return name + + def getValue(self, name): + if name == 'cameraPosition': + return self.render.cameraPosition + if name == 'cameraRotation': + return self.render.cameraRotation + if name == 'playbackSpeed': + return self.playback.speed + if name == 'fieldOfView': + return self.render.fieldOfView + if name == 'nearClip': + return self.render.nearClip + if name == 'farClip': + return self.render.farClip + if name == 'navGridOffset': + return self.render.navGridOffset + if name == 'skyboxRotation': + return self.render.skyboxRotation + if name == 'skyboxRadius': + return self.render.skyboxRadius + if name == 'skyboxOffset': + return self.render.skyboxOffset + if name == 'sunDirection': + return self.render.sunDirection + if name == 'depthFogEnabled': + return self.render.depthFogEnabled + if name == 'depthFogStart': + return self.render.depthFogStart + if name == 'depthFogEnd': + return self.render.depthFogEnd + if name == 'depthFogIntensity': + return self.render.depthFogIntensity + if name == 'depthFogColor': + return self.render.depthFogColor + if name == 'heightFogEnabled': + return self.render.heightFogEnabled + if name == 'heightFogStart': + return self.render.heightFogStart + if name == 'heightFogEnd': + return self.render.heightFogEnd + if name == 'heightFogIntensity': + return self.render.heightFogIntensity + if name == 'heightFogColor': + return self.render.heightFogColor + if name == 'depthOfFieldEnabled': + return self.render.depthOfFieldEnabled + if name == 'depthOfFieldCircle': + return self.render.depthOfFieldCircle + if name == 'depthOfFieldWidth': + return self.render.depthOfFieldWidth + if name == 'depthOfFieldNear': + return self.render.depthOfFieldNear + if name == 'depthOfFieldMid': + return self.render.depthOfFieldMid + if name == 'depthOfFieldFar': + return self.render.depthOfFieldFar diff --git a/leaguedirector/app.py b/leaguedirector/app.py new file mode 100644 index 0000000..8ebf35d --- /dev/null +++ b/leaguedirector/app.py @@ -0,0 +1,986 @@ +import os +import sys +import functools +from PySide2.QtGui import * +from PySide2.QtCore import * +from PySide2.QtWidgets import * +from leaguedirector.widgets import * +from leaguedirector.sequencer import * +from leaguedirector.enable import * +from leaguedirector.api import Game, Playback, Render, Recording, Sequence +from leaguedirector.bindings import Bindings + + +class SkyboxCombo(QComboBox): + def showPopup(self): + appDir = respath('skyboxes') + userDir = userpath('skyboxes') + paths = [''] + paths += [os.path.join(appDir, f) for f in os.listdir(appDir) if f.endswith('.dds')] + paths += [os.path.join(userDir, f) for f in os.listdir(userDir) if f.endswith('.dds')] + self.clear() + for path in sorted(paths): + self.addItem(os.path.basename(path), path) + QComboBox.showPopup(self) + + +class KeybindingsWindow(QScrollArea): + def __init__(self, bindings): + QScrollArea.__init__(self) + self.fields = {} + self.bindings = bindings + self.setWidgetResizable(True) + self.setWindowTitle('Key Bindings') + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + widget = QWidget() + layout = QFormLayout() + for name, value in self.bindings.getBindings().items(): + binding = HBoxWidget() + field = QKeySequenceEdit(QKeySequence(value)) + field.keySequenceChanged.connect(functools.partial(self.edited, name, field)) + clear = QPushButton() + clear.setToolTip('Clear Key Binding') + clear.setFixedWidth(20) + clear.setIcon(self.style().standardIcon(QStyle.SP_DialogCloseButton)) + clear.clicked.connect(functools.partial(self.clear, name, field)) + binding.addWidget(field) + binding.addWidget(clear) + layout.addRow(self.bindings.getLabel(name), binding) + self.fields[name] = field + reset = QPushButton('Reset To Defaults') + reset.clicked.connect(self.reset) + layout.addRow('', reset) + widget.setLayout(layout) + self.setWidget(widget) + + def reset(self): + for name, default in self.bindings.defaults.items(): + sequence = QKeySequence(default) + self.fields[name].setKeySequence(sequence) + self.bindings.setBinding(name, sequence) + + def clear(self, name, field): + field.clear() + self.bindings.setBinding(name, field.keySequence()) + + def edited(self, name, field, *args): + self.bindings.setBinding(name, field.keySequence()) + + +class VisibleWindow(QScrollArea): + options = [ + ('fogOfWar', 'show_fog_of_war', 'Show Fog Of War?'), + ('outlineSelect', 'show_selected_outline', 'Show Selected Outline?'), + ('outlineHover', 'show_hover_outline', 'Show Hover Outline?'), + ('floatingText', 'show_floating_text', 'Show Floating Text?'), + ('interfaceAll', 'show_interface_all', 'Show UI?'), + ('interfaceReplay', 'show_interface_replay', 'Show UI Replay?'), + ('interfaceScore', 'show_interface_score', 'Show UI Score?'), + ('interfaceScoreboard', 'show_interface_scoreboard', 'Show UI Scoreboard?'), + ('interfaceFrames', 'show_interface_frames', 'Show UI Frames?'), + ('interfaceMinimap', 'show_interface_minimap', 'Show UI Minimap?'), + ('interfaceTimeline', 'show_interface_timeline', 'Show UI Timeline?'), + ('interfaceChat', 'show_interface_chat', 'Show UI Chat?'), + ('interfaceTarget', 'show_interface_target', 'Show UI Target?'), + ('interfaceQuests', 'show_interface_quests', 'Show UI Quests?'), + ('interfaceAnnounce', 'show_interface_announce', 'Show UI Announcements?'), + ('healthBarChampions', 'show_healthbar_champions', 'Show Health Champions?'), + ('healthBarStructures', 'show_healthbar_structures', 'Show Health Structures?'), + ('healthBarWards', 'show_healthbar_wards', 'Show Health Wards?'), + ('healthBarPets', 'show_healthbar_pets', 'Show Health Pets?'), + ('healthBarMinions', 'show_healthbar_minions', 'Show Health Minions?'), + ('environment', 'show_environment', 'Show Environment?'), + ('characters', 'show_characters', 'Show Characters?'), + ('particles', 'show_particles', 'Show Particles?'), + ] + + def __init__(self, api): + QScrollArea.__init__(self) + self.api = api + self.api.updated.connect(self.update) + self.inputs = {} + self.bindings = {} + self.setWidgetResizable(True) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setWindowTitle('Visibility') + widget = QWidget() + layout = QFormLayout() + for name, binding, label in self.options: + self.inputs[name] = BooleanInput() + self.inputs[name].valueChanged.connect(functools.partial(self.api.render.set, name)) + self.bindings[binding] = name + layout.addRow(label, self.inputs[name]) + widget.setLayout(layout) + self.setWidget(widget) + + def update(self): + for name, field in self.inputs.items(): + self.api.render.set(name, field.value()) + + def restoreSettings(self, data): + for name, value in data.items(): + if name in self.inputs: + self.inputs[name].update(value) + self.api.render.set(name, value) + + def saveSettings(self): + return {name:self.api.render.get(name) for name in self.inputs} + + def onKeybinding(self, name): + if name in self.bindings: + self.inputs[self.bindings[name]].toggle() + + +class RenderWindow(QScrollArea): + def __init__(self, api): + QScrollArea.__init__(self) + self.api = api + self.api.updated.connect(self.update) + self.cameraMode = QLabel('') + self.cameraLockX = BooleanInput('X') + self.cameraLockY = BooleanInput('Y') + self.cameraLockZ = BooleanInput('Z') + self.cameraPosition = VectorInput() + self.cameraPosition.setSingleStep(10) + self.cameraRotation = VectorInput([0, -90, -90], [360, 90, 90]) + self.cameraAttached = BooleanInput() + self.cameraMoveSpeed = FloatInput(0, 5000) + self.cameraMoveSpeed.setRelativeStep(0.1) + self.cameraLookSpeed = FloatInput(0.01, 5) + self.cameraLookSpeed.setSingleStep(0.01) + self.fieldOfView = FloatInput(0, 180) + self.nearClip = FloatInput() + self.nearClip.setRelativeStep(0.05) + self.farClip = FloatInput() + self.farClip.setRelativeStep(0.05) + self.navGrid = FloatInput(-100, 100) + self.skyboxes = SkyboxCombo() + self.skyboxRotation = FloatInput(-180, 180) + self.skyboxOffset = FloatInput(-10000000, 10000000) + self.skyboxRadius = FloatInput(0, 10000000) + self.skyboxRadius.setSingleStep(10) + self.sunDirection = VectorInput() + self.sunDirection.setSingleStep(0.1) + self.depthFogEnabled = BooleanInput() + self.depthFogStart = FloatInput(0, 100000) + self.depthFogStart.setRelativeStep(0.05) + self.depthFogEnd = FloatInput(0, 100000) + self.depthFogEnd.setRelativeStep(0.05) + self.depthFogIntensity = FloatInput(0, 1) + self.depthFogIntensity.setSingleStep(0.05) + self.depthFogColor = ColorInput() + self.heightFogEnabled = BooleanInput() + self.heightFogStart = FloatInput(-100000, 100000) + self.heightFogStart.setSingleStep(100) + self.heightFogEnd = FloatInput(-100000, 100000) + self.heightFogEnd.setSingleStep(100) + self.heightFogIntensity = FloatInput(0, 1) + self.heightFogIntensity.setSingleStep(0.05) + self.heightFogColor = ColorInput() + self.depthOfFieldEnabled = BooleanInput() + self.depthOfFieldDebug = BooleanInput() + self.depthOfFieldCircle = FloatInput(0, 300) + self.depthOfFieldWidth = FloatInput(0, 100000) + self.depthOfFieldWidth.setSingleStep(100) + self.depthOfFieldNear = FloatInput(0, 100000) + self.depthOfFieldNear.setRelativeStep(0.05) + self.depthOfFieldMid = FloatInput(0, 100000) + self.depthOfFieldMid.setRelativeStep(0.05) + self.depthOfFieldFar = FloatInput(0, 100000) + self.depthOfFieldFar.setRelativeStep(0.05) + + self.cameraLockX.valueChanged.connect(self.api.render.toggleCameraLockX) + self.cameraLockY.valueChanged.connect(self.api.render.toggleCameraLockY) + self.cameraLockZ.valueChanged.connect(self.api.render.toggleCameraLockZ) + self.cameraPosition.valueChanged.connect(functools.partial(self.api.render.set, 'cameraPosition')) + self.cameraRotation.valueChanged.connect(functools.partial(self.api.render.set, 'cameraRotation')) + self.cameraAttached.valueChanged.connect(functools.partial(self.api.render.set, 'cameraAttached')) + self.cameraMoveSpeed.valueChanged.connect(functools.partial(self.api.render.set, 'cameraMoveSpeed')) + self.cameraLookSpeed.valueChanged.connect(functools.partial(self.api.render.set, 'cameraLookSpeed')) + self.fieldOfView.valueChanged.connect(functools.partial(self.api.render.set, 'fieldOfView')) + self.nearClip.valueChanged.connect(functools.partial(self.api.render.set, 'nearClip')) + self.farClip.valueChanged.connect(functools.partial(self.api.render.set, 'farClip')) + self.navGrid.valueChanged.connect(functools.partial(self.api.render.set, 'navGridOffset')) + self.skyboxes.activated.connect(lambda index: self.api.render.set('skyboxPath', self.skyboxes.itemData(index))) + self.skyboxRotation.valueChanged.connect(functools.partial(self.api.render.set, 'skyboxRotation')) + self.skyboxRadius.valueChanged.connect(functools.partial(self.api.render.set, 'skyboxRadius')) + self.skyboxOffset.valueChanged.connect(functools.partial(self.api.render.set, 'skyboxOffset')) + self.sunDirection.valueChanged.connect(functools.partial(self.api.render.set, 'sunDirection')) + self.depthFogEnabled.valueChanged.connect(functools.partial(self.api.render.set, 'depthFogEnabled')) + self.depthFogStart.valueChanged.connect(functools.partial(self.api.render.set, 'depthFogStart')) + self.depthFogEnd.valueChanged.connect(functools.partial(self.api.render.set, 'depthFogEnd')) + self.depthFogIntensity.valueChanged.connect(functools.partial(self.api.render.set, 'depthFogIntensity')) + self.depthFogColor.valueChanged.connect(functools.partial(self.api.render.set, 'depthFogColor')) + self.heightFogEnabled.valueChanged.connect(functools.partial(self.api.render.set, 'heightFogEnabled')) + self.heightFogStart.valueChanged.connect(functools.partial(self.api.render.set, 'heightFogStart')) + self.heightFogEnd.valueChanged.connect(functools.partial(self.api.render.set, 'heightFogEnd')) + self.heightFogIntensity.valueChanged.connect(functools.partial(self.api.render.set, 'heightFogIntensity')) + self.heightFogColor.valueChanged.connect(functools.partial(self.api.render.set, 'heightFogColor')) + self.depthOfFieldEnabled.valueChanged.connect(functools.partial(self.api.render.set, 'depthOfFieldEnabled')) + self.depthOfFieldDebug.valueChanged.connect(functools.partial(self.api.render.set, 'depthOfFieldDebug')) + self.depthOfFieldCircle.valueChanged.connect(functools.partial(self.api.render.set, 'depthOfFieldCircle')) + self.depthOfFieldWidth.valueChanged.connect(functools.partial(self.api.render.set, 'depthOfFieldWidth')) + self.depthOfFieldNear.valueChanged.connect(functools.partial(self.api.render.set, 'depthOfFieldNear')) + self.depthOfFieldMid.valueChanged.connect(functools.partial(self.api.render.set, 'depthOfFieldMid')) + self.depthOfFieldFar.valueChanged.connect(functools.partial(self.api.render.set, 'depthOfFieldFar')) + + widget = QWidget() + layout = QFormLayout() + layout.addRow('Camera Mode', self.cameraMode) + layout.addRow('Camera Lock', HBoxWidget(self.cameraLockX, self.cameraLockY, self.cameraLockZ)) + layout.addRow('Camera Position', self.cameraPosition) + layout.addRow('Camera Rotation', self.cameraRotation) + layout.addRow('Camera Attached', self.cameraAttached) + layout.addRow('Camera Move Speed', self.cameraMoveSpeed) + layout.addRow('Camera Look Speed', self.cameraLookSpeed) + layout.addRow('Field of View', self.fieldOfView) + layout.addRow('Near Clip', self.nearClip) + layout.addRow('Far Clip', self.farClip) + layout.addRow('Nav Grid Offset', self.navGrid) + layout.addRow(Separator()) + layout.addRow('Skybox', self.skyboxes) + layout.addRow('Skybox Rotation', self.skyboxRotation) + layout.addRow('Skybox Offset', self.skyboxOffset) + layout.addRow('Skybox Radius', self.skyboxRadius) + layout.addRow('Sun Direction', self.sunDirection) + layout.addRow(Separator()) + layout.addRow('Depth Fog', self.depthFogEnabled) + layout.addRow('Depth Fog Start', self.depthFogStart) + layout.addRow('Depth Fog End', self.depthFogEnd) + layout.addRow('Depth Fog Intensity', self.depthFogIntensity) + layout.addRow('Depth Fog Color', self.depthFogColor) + layout.addRow(Separator()) + layout.addRow('Height Fog', self.heightFogEnabled) + layout.addRow('Height Fog Start', self.heightFogStart) + layout.addRow('Height Fog End', self.heightFogEnd) + layout.addRow('Height Fog Intensity', self.heightFogIntensity) + layout.addRow('Height Fog Color', self.heightFogColor) + layout.addRow(Separator()) + layout.addRow('Depth of Field', self.depthOfFieldEnabled) + layout.addRow('Depth of Field Debug', self.depthOfFieldDebug) + layout.addRow('Depth of Field Circle', self.depthOfFieldCircle) + layout.addRow('Depth of Field Width', self.depthOfFieldWidth) + layout.addRow('Depth of Field Near', self.depthOfFieldNear) + layout.addRow('Depth of Field Mid', self.depthOfFieldMid) + layout.addRow('Depth of Field Far', self.depthOfFieldFar) + widget.setLayout(layout) + self.setWidgetResizable(True) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setWidget(widget) + self.setWindowTitle('Rendering') + + def update(self): + self.cameraLockX.update(self.api.render.cameraLockX is not None) + self.cameraLockY.update(self.api.render.cameraLockY is not None) + self.cameraLockZ.update(self.api.render.cameraLockZ is not None) + self.cameraLockX.setCheckboxText('{0:.2f}'.format(self.api.render.cameraLockX) if self.api.render.cameraLockX else 'X') + self.cameraLockY.setCheckboxText('{0:.2f}'.format(self.api.render.cameraLockY) if self.api.render.cameraLockY else 'Y') + self.cameraLockZ.setCheckboxText('{0:.2f}'.format(self.api.render.cameraLockZ) if self.api.render.cameraLockZ else 'Z') + self.cameraMode.setText(self.api.render.cameraMode) + self.cameraPosition.update(self.api.render.cameraPosition) + self.cameraRotation.update(self.api.render.cameraRotation) + self.cameraAttached.update(self.api.render.cameraAttached) + self.cameraMoveSpeed.update(self.api.render.cameraMoveSpeed) + self.cameraLookSpeed.update(self.api.render.cameraLookSpeed) + self.fieldOfView.update(self.api.render.fieldOfView) + self.nearClip.update(self.api.render.nearClip) + self.farClip.update(self.api.render.farClip) + self.navGrid.update(self.api.render.navGridOffset) + self.skyboxRotation.update(self.api.render.skyboxRotation) + self.skyboxRadius.update(self.api.render.skyboxRadius) + self.skyboxOffset.update(self.api.render.skyboxOffset) + self.skyboxOffset.setRange(-self.api.render.skyboxRadius, self.api.render.skyboxRadius) + self.skyboxOffset.setSingleStep(self.api.render.skyboxRadius / 1000) + self.sunDirection.update(self.api.render.sunDirection) + self.depthFogEnabled.update(self.api.render.depthFogEnabled) + self.depthFogStart.update(self.api.render.depthFogStart) + self.depthFogEnd.update(self.api.render.depthFogEnd) + self.depthFogIntensity.update(self.api.render.depthFogIntensity) + self.depthFogColor.update(self.api.render.depthFogColor) + self.heightFogEnabled.update(self.api.render.heightFogEnabled) + self.heightFogStart.update(self.api.render.heightFogStart) + self.heightFogEnd.update(self.api.render.heightFogEnd) + self.heightFogIntensity.update(self.api.render.heightFogIntensity) + self.heightFogColor.update(self.api.render.heightFogColor) + self.depthOfFieldEnabled.update(self.api.render.depthOfFieldEnabled) + self.depthOfFieldDebug.update(self.api.render.depthOfFieldDebug) + self.depthOfFieldCircle.update(self.api.render.depthOfFieldCircle) + self.depthOfFieldWidth.update(self.api.render.depthOfFieldWidth) + self.depthOfFieldNear.update(self.api.render.depthOfFieldNear) + self.depthOfFieldMid.update(self.api.render.depthOfFieldMid) + self.depthOfFieldFar.update(self.api.render.depthOfFieldFar) + + +class RecordingWindow(VBoxWidget): + def __init__(self, api): + VBoxWidget.__init__(self) + self.api = api + self.api.updated.connect(self.update) + self.recordings = set() + + self.codec = QComboBox() + self.codec.addItem('webm') + self.codec.addItem('png') + self.startTime = FloatInput(0, 100) + self.endTime = FloatInput(0, 100) + self.fps = FloatInput(0, 400) + self.fps.setValue(60) + self.lossless = BooleanInput() + + self.outputPath = userpath('recordings') + self.outputLabel = QLabel() + self.outputLabel.setTextFormat(Qt.RichText) + self.outputLabel.setTextInteractionFlags(Qt.TextBrowserInteraction) + self.outputLabel.setOpenExternalLinks(True) + + self.outputButton = QPushButton() + self.outputButton.setToolTip('Change Output Directory') + self.outputButton.setFixedWidth(30) + self.outputButton.setIcon(self.style().standardIcon(QStyle.SP_FileDialogStart)) + self.outputButton.clicked.connect(self.selectOutputDirectory) + + self.button = QPushButton('Record') + self.button.clicked.connect(self.startRecording) + self.button2 = QPushButton('Record Sequence') + self.button2.clicked.connect(self.recordSequence) + self.list = QListWidget() + self.list.setSortingEnabled(True) + self.list.itemDoubleClicked.connect(self.openRecording) + + self.form = QWidget(self) + self.formLayout = QFormLayout(self.form) + self.formLayout.addRow('Codec', self.codec) + self.formLayout.addRow('Start Time', self.startTime) + self.formLayout.addRow('End Time', self.endTime) + self.formLayout.addRow('Frames Per Second', self.fps) + self.formLayout.addRow('Lossless Encoding', self.lossless) + self.formLayout.addRow('Output Directory', HBoxWidget(self.outputButton, self.outputLabel)) + self.formLayout.addRow(HBoxWidget(self.button, self.button2)) + self.formLayout.addRow(self.list) + self.form.setLayout(self.formLayout) + + self.render = QWidget(self) + self.progress = QProgressBar() + self.cancel = QPushButton('Cancel Recording') + self.cancel.clicked.connect(self.stopRecording) + self.renderLayout = QFormLayout() + self.renderLayout.addRow(QLabel('Rendering video...')) + self.renderLayout.addRow(self.progress) + self.renderLayout.addRow(self.cancel) + self.render.setLayout(self.renderLayout) + + self.addWidget(self.form) + self.addWidget(self.render) + self.setWindowTitle('Recording') + + def update(self): + self.startTime.setRange(0, self.api.playback.length) + self.endTime.setRange(0, self.api.playback.length) + self.render.setVisible(self.api.recording.recording) + self.form.setVisible(not self.api.recording.recording) + if self.api.recording.recording: + self.progress.setMinimum(self.api.recording.startTime * 1000) + self.progress.setMaximum(self.api.recording.endTime * 1000) + self.progress.setValue(self.api.recording.currentTime * 1000) + if self.api.recording.path not in self.recordings: + self.list.addItem(self.api.recording.path) + self.recordings.add(self.api.recording.path) + + def selectOutputDirectory(self): + self.setOutputDirectory(QFileDialog.getExistingDirectory(self, 'Select Output Directory', self.outputPath)) + + def openRecording(self, item): + QDesktopServices.openUrl(QUrl('file:///{}'.format(item.text()))) + + def stopRecording(self): + self.api.recording.update({'recording' : False}) + + def startRecording(self): + self.api.playback.play() + self.api.recording.update({ + 'recording' : True, + 'codec' : self.codec.currentText(), + 'startTime' : self.startTime.value(), + 'endTime' : self.endTime.value(), + 'framesPerSecond' : self.fps.value(), + 'enforceFrameRate' : True, + 'lossless' : self.lossless.value(), + 'path' : self.outputPath, + }) + + def setOutputDirectory(self, path): + if os.path.exists(path): + self.outputPath = path + self.outputLabel.setText("{}".format(path, path)) + + def recordSequence(self): + self.api.sequence.setSequencing(True) + self.startTime.setValue(self.api.sequence.startTime) + self.endTime.setValue(self.api.sequence.endTime) + self.startRecording() + + def saveSettings(self): + return {'output': self.outputPath} + + def restoreSettings(self, data): + self.setOutputDirectory(data.get('output', self.outputPath)) + + +class TimelineWindow(QWidget): + def __init__(self, api): + QWidget.__init__(self) + self.api = api + self.api.updated.connect(self.update) + self.timer = schedule(10, self.animate) + self.sequenceHeaders = SequenceHeaderView(self.api) + self.sequenceTracks = SequenceTrackView(self.api, self.sequenceHeaders) + layout = QVBoxLayout() + self.layoutSpeed(layout) + self.layoutTimeButtons(layout) + self.layoutSlider(layout) + self.layoutSequencer(layout) + self.setWindowTitle('Timeline') + self.setLayout(layout) + + def saveSettings(self): + return {'directory': self.api.sequence.directory} + + def restoreSettings(self, data): + self.api.sequence.setDirectory(data.get('directory', userpath('sequences'))) + + def selectDirectory(self): + self.api.sequence.setDirectory(QFileDialog.getExistingDirectory(self, 'Select Directory', self.api.sequence.directory)) + + def layoutSequencer(self, layout): + self.sequenceCombo = SequenceCombo(self.api) + self.sequenceButton = QPushButton() + self.sequenceButton.setToolTip('Open Directory') + self.sequenceButton.setFixedWidth(30) + self.sequenceButton.setIcon(self.style().standardIcon(QStyle.SP_FileDialogStart)) + self.sequenceButton.clicked.connect(self.selectDirectory) + layout.addWidget(HBoxWidget(self.sequenceCombo, self.sequenceButton)) + + widget = HBoxWidget() + self.applySequence = BooleanInput('Apply Sequence?') + self.applySequence.valueChanged.connect(self.api.sequence.setSequencing) + widget.addWidget(self.applySequence) + playSequence = QPushButton('Play Sequence') + playSequence.setMaximumWidth(150) + playSequence.clicked.connect(self.playSequence) + widget.addWidget(playSequence) + copySequence = QPushButton('Copy Sequence') + copySequence.setMaximumWidth(150) + copySequence.clicked.connect(self.copySequence) + widget.addWidget(copySequence) + newSequence = QPushButton('New Sequence') + newSequence.setMaximumWidth(150) + newSequence.clicked.connect(self.newSequence) + widget.addWidget(newSequence) + layout.addWidget(widget) + + widget = HBoxWidget() + widget.addWidget(self.sequenceHeaders) + widget.addWidget(self.sequenceTracks) + layout.addWidget(widget) + + sequenceSelection = SequenceSelectedView(self.api, self.sequenceTracks) + layout.addWidget(sequenceSelection) + + def layoutSpeed(self, layout): + widget = HBoxWidget() + self.play = QPushButton("") + self.play.clicked.connect(self.api.playback.togglePlay) + widget.addWidget(self.play) + self.speed = FloatSlider('Speed') + self.speed.setRange(0, 8.0) + self.speed.setSingleStep(0.1) + self.speed.valueChanged.connect(lambda: self.api.playback.setSpeed(self.speed.value())) + widget.addWidget(self.speed) + for speed in [0.5, 1, 2, 4]: + button = QPushButton("x{}".format(speed)) + button.setMaximumWidth(35) + button.clicked.connect(functools.partial(self.api.playback.setSpeed, speed)) + widget.addWidget(button) + layout.addWidget(widget) + + def layoutTimeButtons(self, layout): + widget = HBoxWidget() + for delta in [-120, -60, -30, -10, -5, 5, 10, 30, 60, 120]: + sign = '+' if delta > 0 else '' + button = QPushButton('{}{}s'.format(sign, delta)) + button.setMinimumWidth(40) + button.clicked.connect(functools.partial(self.api.playback.adjustTime, delta)) + widget.addWidget(button) + layout.addWidget(widget) + + def layoutSlider(self, layout): + widget = VBoxWidget() + self.timeLabel = QLabel("") + self.timeSlider = QSlider(Qt.Horizontal) + self.timeSlider.setTickPosition(QSlider.TicksBelow) + self.timeSlider.setTickInterval(60000) + self.timeSlider.setTracking(False) + self.timeSlider.sliderReleased.connect(self.onTimeline) + widget.addWidget(self.timeLabel) + widget.addWidget(self.timeSlider) + layout.addWidget(widget) + + def onTimeline(self): + self.api.playback.time = self.timeSlider.sliderPosition() / 1000 + + def newSequence(self): + name, ok = QInputDialog.getText(self, 'Create New Sequence', 'Enter a name for your sequence') + if ok: + self.api.sequence.create(name) + + def copySequence(self): + name, ok = QInputDialog.getText(self, 'Copy Sequence', 'Enter a name to save a new copy of your sequence') + if ok: + self.api.sequence.copy(name) + + def playSequence(self): + self.api.sequence.setSequencing(True) + self.api.playback.play(self.api.sequence.startTime) + + def onKeybinding(self, name): + if name == 'sequence_del_kf': + self.sequenceTracks.deleteSelectedKeyframes() + elif name == 'sequence_next_kf': + self.sequenceTracks.selectNextKeyframe() + elif name == 'sequence_prev_kf': + self.sequenceTracks.selectPrevKeyframe() + elif name == 'sequence_adj_kf': + self.sequenceTracks.selectAdjacentKeyframes() + elif name == 'sequence_all_kf': + self.sequenceTracks.selectAllKeyframes() + elif name == 'sequence_seek_kf': + self.sequenceTracks.seekSelectedKeyframe() + elif name == 'sequence_apply': + self.applySequence.toggle() + elif name == 'sequence_play': + self.playSequence() + elif name == 'sequence_new': + self.newSequence() + elif name == 'sequence_copy': + self.copySequence() + elif name == 'sequence_clear': + self.sequenceTracks.clearKeyframes() + elif name == 'sequence_undo': + self.api.sequence.undo() + elif name == 'sequence_redo': + self.api.sequence.redo() + elif name == 'kf_position': + self.sequenceTracks.addKeyframe('cameraPosition') + elif name == 'kf_rotation': + self.sequenceTracks.addKeyframe('cameraRotation') + elif name == 'kf_speed': + self.sequenceTracks.addKeyframe('playbackSpeed') + elif name == 'kf_fov': + self.sequenceTracks.addKeyframe('fieldOfView') + elif name == 'kf_near_clip': + self.sequenceTracks.addKeyframe('nearClip') + elif name == 'kf_far_clip': + self.sequenceTracks.addKeyframe('farClip') + elif name == 'kf_nav_grid': + self.sequenceTracks.addKeyframe('navGridOffset') + elif name == 'kf_sky_rotation': + self.sequenceTracks.addKeyframe('skyboxRotation') + elif name == 'kf_sky_radius': + self.sequenceTracks.addKeyframe('skyboxRadius') + elif name == 'kf_sky_offset': + self.sequenceTracks.addKeyframe('skyboxOffset') + elif name == 'kf_sun_direction': + self.sequenceTracks.addKeyframe('sunDirection') + elif name == 'kf_depth_fog_enable': + self.sequenceTracks.addKeyframe('depthFogEnabled') + elif name == 'kf_depth_fog_start': + self.sequenceTracks.addKeyframe('depthFogStart') + elif name == 'kf_depth_fog_end': + self.sequenceTracks.addKeyframe('depthFogEnd') + elif name == 'kf_depth_fog_intensity': + self.sequenceTracks.addKeyframe('depthFogIntensity') + elif name == 'kf_depth_fog_color': + self.sequenceTracks.addKeyframe('depthFogColor') + elif name == 'kf_height_fog_enable': + self.sequenceTracks.addKeyframe('heightFogEnabled') + elif name == 'kf_height_fog_start': + self.sequenceTracks.addKeyframe('heightFogStart') + elif name == 'kf_height_fog_end': + self.sequenceTracks.addKeyframe('heightFogEnd') + elif name == 'kf_height_fog_intensity': + self.sequenceTracks.addKeyframe('heightFogIntensity') + elif name == 'kf_height_fog_color': + self.sequenceTracks.addKeyframe('heightFogColor') + elif name == 'kf_dof_enabled': + self.sequenceTracks.addKeyframe('depthOfFieldEnabled') + elif name == 'kf_dof_circle': + self.sequenceTracks.addKeyframe('depthOfFieldCircle') + elif name == 'kf_dof_width': + self.sequenceTracks.addKeyframe('depthOfFieldWidth') + elif name == 'kf_dof_near': + self.sequenceTracks.addKeyframe('depthOfFieldNear') + elif name == 'kf_dof_mid': + self.sequenceTracks.addKeyframe('depthOfFieldMid') + elif name == 'kf_dof_far': + self.sequenceTracks.addKeyframe('depthOfFieldFar') + + def formatTime(self, t): + minutes, seconds = divmod(t, 60) + return '{0:02}:{1:05.2f}'.format(int(minutes), seconds) + + def animate(self): + self.speed.update(self.api.playback.speed) + self.timeSlider.setRange(0, self.api.playback.length * 1000) + if self.timeSlider.isSliderDown(): + self.timeLabel.setText(self.formatTime(self.timeSlider.sliderPosition() / 1000)) + else: + self.timeLabel.setText(self.formatTime(self.api.playback.currentTime)) + self.timeSlider.setValue(self.api.playback.currentTime * 1000) + + def update(self): + self.applySequence.update(self.api.sequence.sequencing) + if self.api.playback.seeking: + self.play.setDisabled(True) + self.play.setText('Seeking') + elif self.api.playback.paused: + self.play.setDisabled(False) + self.play.setText('Play') + else: + self.play.setDisabled(False) + self.play.setText('Pause') + + +class Api(QObject): + updated = Signal() + + def __init__(self): + QObject.__init__(self) + self.game = Game() + self.render = Render() + self.playback = Playback() + self.recording = Recording() + self.sequence = Sequence(self.render, self.playback) + self.game.updated.connect(self.updated.emit) + self.render.updated.connect(self.updated.emit) + self.playback.updated.connect(self.updated.emit) + self.recording.updated.connect(self.updated.emit) + + def update(self): + self.game.update() + self.render.update() + self.playback.update() + self.recording.update() + + def onKeybinding(self, name): + if name == 'camera_up': + self.render.moveCamera(y=7) + elif name == 'camera_down': + self.render.moveCamera(y=-7) + elif name == 'camera_move_speed_up': + self.render.cameraMoveSpeed = self.render.cameraMoveSpeed * 1.2 + elif name == 'camera_move_speed_down': + self.render.cameraMoveSpeed = self.render.cameraMoveSpeed * 0.8 + elif name == 'camera_look_speed_up': + self.render.cameraLookSpeed = self.render.cameraLookSpeed * 1.1 + elif name == 'camera_look_speed_down': + self.render.cameraLookSpeed = self.render.cameraLookSpeed * 0.9 + elif name == 'camera_yaw_left': + self.render.rotateCamera(x=-1) + elif name == 'camera_yaw_right': + self.render.rotateCamera(x=1) + elif name == 'camera_pitch_up': + self.render.rotateCamera(y=-1) + elif name == 'camera_pitch_down': + self.render.rotateCamera(y=1) + elif name == 'camera_roll_left': + self.render.rotateCamera(z=1) + elif name == 'camera_roll_right': + self.render.rotateCamera(z=-1) + elif name == 'camera_lock_x': + self.render.toggleCameraLockX() + elif name == 'camera_lock_y': + self.render.toggleCameraLockY() + elif name == 'camera_lock_z': + self.render.toggleCameraLockZ() + elif name == 'camera_attach': + self.render.cameraAttached = not self.render.cameraAttached + elif name == 'camera_fov_up': + self.render.fieldOfView = self.render.fieldOfView * 1.05 + elif name == 'camera_fov_down': + self.render.fieldOfView = self.render.fieldOfView * 0.95 + elif name == 'play_pause': + self.playback.paused = not self.playback.paused + elif name == 'time_minus_120': + self.playback.adjustTime(-120) + elif name == 'time_minus_60': + self.playback.adjustTime(-60) + elif name == 'time_minus_30': + self.playback.adjustTime(-30) + elif name == 'time_minus_10': + self.playback.adjustTime(-10) + elif name == 'time_minus_5': + self.playback.adjustTime(-5) + elif name == 'time_plus_5': + self.playback.adjustTime(5) + elif name == 'time_plus_10': + self.playback.adjustTime(10) + elif name == 'time_plus_30': + self.playback.adjustTime(30) + elif name == 'time_plus_60': + self.playback.adjustTime(60) + elif name == 'time_plus_120': + self.playback.adjustTime(120) + + +class ConnectWindow(QDialog): + def __init__(self): + QDialog.__init__(self) + self.setWindowTitle('Ready To Connect') + self.layout = QVBoxLayout() + self.setLayout(self.layout) + self.setWindowModality(Qt.WindowModal) + self.welcome = QLabel() + self.welcome.setText(""" +
https://github.com/riotgames/leaguedirector/
+Please make sure your League of Legends install is enabled by ticking the boxes below.
+Once enabled, start up a replay in the League of Legends client to begin.