├── .github
├── CODEOWNERS
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── pull_request_template.md
├── .gitignore
├── .mergify.yml
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE-APACHE
├── LICENSE-MIT
├── README.md
├── docs
├── Makefile
├── make.bat
└── source
│ ├── client.rst
│ ├── conf.py
│ ├── general.rst
│ ├── index.rst
│ ├── modules.rst
│ ├── nisse.md
│ ├── server.rst
│ ├── server_commands.rst
│ ├── server_functionality.rst
│ ├── skyhook.modules.rst
│ ├── skyhook.rst
│ └── unreal_engine_server.rst
├── examples
├── blender_client.py
└── server_in_blender.py
├── icon
├── sky_hook_logo.png
└── sky_hook_logo.psd
├── package.py
├── setup.py
├── skyhook
├── __init__.py
├── client.py
├── constants.py
├── logger.py
├── modules
│ ├── __init__.py
│ ├── blender.py
│ ├── core.py
│ ├── houdini.py
│ ├── maya_mod.py
│ ├── substance_painter.py
│ └── unreal.py
├── resources
│ ├── __init__.py
│ ├── favicon.ico
│ ├── sky_hook_label_transparent.png
│ └── sky_hook_logo_transparent.png
└── server.py
└── wiki-images
├── UE_Logo_Icon_Black.png
├── blender_logo.png
├── dragon_martin_woortman.png
├── houdinibadge.jpg
├── houdinibadge.png
├── maya_logo.png
└── substance_painter.png
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # Documentation for this file can be found on the GitHub website here:
2 | # https://docs.github.com/en/free-pro-team@latest/github/creating-cloning-and-archiving-repositories/about-code-owners
3 | #
4 | # TODO: uncomment this line and set the maintainers' GitHub usernames
5 | * @nielsvaes
6 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Device:**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Additional context**
32 | Add any other context about the problem here.
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ### Checklist
2 |
3 | * [ ] I have read the [Contributor Guide](../../CONTRIBUTING.md)
4 | * [ ] I have read and agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
5 | * [ ] I have added a description of my changes and why I'd like them included in the section below
6 |
7 | ### Description of Changes
8 |
9 | Describe your changes here
10 |
11 | ### Related Issues
12 |
13 | List related issues here
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | **/*.rs.bk
3 | Cargo.lock
4 |
5 | # Created by .ignore support plugin (hsz.mobi)
6 | ### Python template
7 | # Byte-compiled / optimized / DLL files
8 | __pycache__/
9 | *.py[cod]
10 | *$py.class
11 |
12 | # C extensions
13 | *.so
14 |
15 | # Distribution / packaging
16 | .Python
17 | build/
18 | develop-eggs/
19 | dist/
20 | downloads/
21 | eggs/
22 | .eggs/
23 | lib/
24 | lib64/
25 | parts/
26 | sdist/
27 | var/
28 | wheels/
29 | pip-wheel-metadata/
30 | share/python-wheels/
31 | *.egg-info/
32 | .installed.cfg
33 | *.egg
34 | MANIFEST
35 |
36 | # PyInstaller
37 | # Usually these files are written by a python script from a template
38 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
39 | *.manifest
40 | *.spec
41 |
42 | # Installer logs
43 | pip-log.txt
44 | pip-delete-this-directory.txt
45 |
46 | # Unit test / coverage reports
47 | htmlcov/
48 | .tox/
49 | .nox/
50 | .coverage
51 | .coverage.*
52 | .cache
53 | nosetests.xml
54 | coverage.xml
55 | *.cover
56 | .hypothesis/
57 | .pytest_cache/
58 |
59 | # Translations
60 | *.mo
61 | *.pot
62 |
63 | # Django stuff:
64 | *.log
65 | local_settings.py
66 | db.sqlite3
67 | db.sqlite3-journal
68 |
69 | # Flask stuff:
70 | instance/
71 | .webassets-cache
72 |
73 | # Scrapy stuff:
74 | .scrapy
75 |
76 | # Sphinx documentation
77 | docs/_build/
78 |
79 | # PyBuilder
80 | target/
81 |
82 | # Jupyter Notebook
83 | .ipynb_checkpoints
84 |
85 | # IPython
86 | profile_default/
87 | ipython_config.py
88 |
89 | # pyenv
90 | .python-version
91 |
92 | # pipenv
93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
96 | # install all needed dependencies.
97 | #Pipfile.lock
98 |
99 | # celery beat schedule file
100 | celerybeat-schedule
101 |
102 | # SageMath parsed files
103 | *.sage.py
104 |
105 | # Environments
106 | .env
107 | .venv
108 | env/
109 | venv/
110 | ENV/
111 | env.bak/
112 | venv.bak/
113 |
114 | # Spyder project settings
115 | .spyderproject
116 | .spyproject
117 |
118 | # Rope project settings
119 | .ropeproject
120 |
121 | # mkdocs documentation
122 | /site
123 |
124 | # mypy
125 | .mypy_cache/
126 | .dmypy.json
127 | dmypy.json
128 |
129 | # Pyre type checker
130 | .pyre/
131 |
132 | .idea
133 |
--------------------------------------------------------------------------------
/.mergify.yml:
--------------------------------------------------------------------------------
1 | pull_request_rules:
2 | - name: automatic merge when CI passes and 1 reviews
3 | conditions:
4 | - "#approved-reviews-by>=1"
5 | - "#review-requested=0"
6 | - "#changes-requested-reviews-by=0"
7 | - base=main
8 | actions:
9 | merge:
10 | method: squash
11 | - name: delete head branch after merge
12 | conditions:
13 | - merged
14 | actions:
15 | delete_head_branch: {}
16 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | ## [Unreleased]
8 |
9 | ## [0.1.1] - 2019-09-03
10 | ### Added
11 | - New features go here in a bullet list
12 |
13 | ### Changed
14 | - Changes to existing functionality go here in a bullet list
15 |
16 | ### Deprecated
17 | - Mark features soon-to-be removed in a bullet list
18 |
19 | ### Removed
20 | - Features that have been removed in a bullet list
21 |
22 | ### Fixed
23 | - Bug fixes in a bullet list
24 |
25 | ### Security
26 | - Changes/fixes related to security vulnerabilities in a bullet list
27 |
28 | ## [0.1.0] - 2019-09-02
29 | ### Added
30 | - Initial add of the thing
31 |
32 | [Unreleased]: https://github.com/EmbarkStudios/$REPO_NAME/compare/0.1.1...HEAD
33 | [0.1.1]: https://github.com/EmbarkStudios/$REPO_NAME/compare/0.1.0...0.1.1
34 | [0.1.0]: https://github.com/EmbarkStudios/$REPO_NAME/releases/tag/0.1.0
35 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at opensource@embark-studios.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Embark Contributor Guidelines
2 |
3 | Welcome! This project is created by the team at [Embark Studios](https://embark.games). We're glad you're interested in contributing! We welcome contributions from people of all backgrounds who are interested in making great software with us.
4 |
5 | At Embark, we aspire to empower everyone to create interactive experiences. To do this, we're exploring and pushing the boundaries of new technologies, and sharing our learnings with the open source community.
6 |
7 | If you have ideas for collaboration, email us at opensource@embark-studios.com.
8 |
9 | We're also hiring full-time engineers to work with us in Stockholm! Check out our current job postings [here](https://www.embark-studios.com/jobs).
10 |
11 | ## Issues
12 |
13 | ### Feature Requests
14 |
15 | If you have ideas or how to improve our projects, you can suggest features by opening a GitHub issue. Make sure to include details about the feature or change, and describe any uses cases it would enable.
16 |
17 | Feature requests will be tagged as `enhancement` and their status will be updated in the comments of the issue.
18 |
19 | ### Bugs
20 |
21 | When reporting a bug or unexpected behaviour in a project, make sure your issue describes steps to reproduce the behaviour, including the platform you were using, what steps you took, and any error messages.
22 |
23 | Reproducible bugs will be tagged as `bug` and their status will be updated in the comments of the issue.
24 |
25 | ### Wontfix
26 |
27 | Issues will be closed and tagged as `wontfix` if we decide that we do not wish to implement it, usually due to being misaligned with the project vision or out of scope. We will comment on the issue with more detailed reasoning.
28 |
29 | ## Contribution Workflow
30 |
31 | ### Open Issues
32 |
33 | If you're ready to contribute, start by looking at our open issues tagged as [`help wanted`](../../issues?q=is%3Aopen+is%3Aissue+label%3A"help+wanted") or [`good first issue`](../../issues?q=is%3Aopen+is%3Aissue+label%3A"good+first+issue").
34 |
35 | You can comment on the issue to let others know you're interested in working on it or to ask questions.
36 |
37 | ### Making Changes
38 |
39 | 1. Fork the repository.
40 |
41 | 2. Create a new feature branch.
42 |
43 | 3. Make your changes. Ensure that there are no build errors by running the project with your changes locally.
44 |
45 | 4. Open a pull request with a name and description of what you did. You can read more about working with pull requests on GitHub [here](https://help.github.com/en/articles/creating-a-pull-request-from-a-fork).
46 |
47 | 5. A maintainer will review your pull request and may ask you to make changes.
48 |
49 | ## Code Guidelines
50 |
51 | ### Rust
52 |
53 | You can read about our standards and recommendations for working with Rust [here](https://github.com/EmbarkStudios/rust-ecosystem/blob/main/guidelines.md).
54 |
55 | ### Python
56 |
57 | We recommend following [PEP8 conventions](https://www.python.org/dev/peps/pep-0008/) when working with Python modules.
58 |
59 | ### JavaScript & TypeScript
60 |
61 | We use [Prettier](https://prettier.io/) with the default settings to auto-format our JavaScript and TypeScript code.
62 |
63 | ## Licensing
64 |
65 | Unless otherwise specified, all Embark open source projects are licensed under a dual MIT OR Apache-2.0 license, allowing licensees to chose either at their option. You can read more in each project's respective README.
66 |
67 | ## Code of Conduct
68 |
69 | Please note that our projects are released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md) to ensure that they are welcoming places for everyone to contribute. By participating in any Embark open source project, you agree to abide by these terms.
70 |
--------------------------------------------------------------------------------
/LICENSE-APACHE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/LICENSE-MIT:
--------------------------------------------------------------------------------
1 | Copyright (c) 2019 Embark Studios
2 |
3 | Permission is hereby granted, free of charge, to any
4 | person obtaining a copy of this software and associated
5 | documentation files (the "Software"), to deal in the
6 | Software without restriction, including without
7 | limitation the rights to use, copy, modify, merge,
8 | publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software
10 | is furnished to do so, subject to the following
11 | conditions:
12 |
13 | The above copyright notice and this permission notice
14 | shall be included in all copies or substantial portions
15 | of the Software.
16 |
17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
25 | DEALINGS IN THE SOFTWARE.
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🌴 SkyHook
2 |
3 | [](https://embark.dev)
4 |
5 | ## Engine and DCC communication system
6 |
7 | SkyHook was created to facilitate communication between DCCs, standalone applications, web browsers and game engines. As of right now, it’s working in Houdini, Blender, Maya, Substance Painter and Unreal Engine.
8 |
9 |
10 |
11 |  |
12 |  |
13 |  |
14 |  |
15 |  |
16 |
17 |
18 |
19 | The current mainline version is for Python 3.6 and up.
20 |
21 | SkyHook consist of 2 parts that can, but don’t have to, work together. There’s a client and a server. The server is just a very simple HTTP server that takes JSON requests. It parses those requests and tries to execute what was in them. The client just makes a a POST request to the server with a JSON payload. This is why you can basically use anything that’s able to do so as a client. Could be in a language other than Python, or even just webpage or mobile application.
22 |
23 | # Release 3.0
24 | I removed dependencies on PySide altogether, since they were causing unnecessary bloat to the package.
25 |
26 | # Quick Start
27 | Some quick start examples to help you get on your way.
28 |
29 | ## Pip installing
30 |
31 | You should be able to pip install this package like this:
32 | ```batch
33 | pip install --upgrade git+https://github.com/EmbarkStudios/skyhook
34 | ```
35 |
36 | ## Maya
37 | Let's say you have a file called `skyhook_commands.py` that is available in `sys.path` inside of Maya. The following are some example functions you could have:
38 |
39 | `skyhook_commands.py`
40 |
41 | ```python
42 | import os
43 | import maya.cmds as cmds
44 | import pymel.core as pm
45 |
46 | def delete_namespace(namespace, nuke_all_contents=False):
47 | """
48 | Removes all objects from the given namespace and then removes it.
49 | If given the nuke flag, will delete all objects from the namespace too.
50 |
51 | :param namespace: *string*
52 | :param nuke_all_contents: *bool* whether to also delete all objects contained in namespace and their children
53 | :return:
54 | """
55 | try:
56 | if nuke_all_contents is not True:
57 | pm.namespace(force=True, rm=namespace, mergeNamespaceWithRoot=True)
58 | pm.namespace (set=':')
59 | else:
60 | nmspc_children = cmds.namespaceInfo(namespace, listOnlyNamespaces=True, recurse=True)
61 | if nmspc_children:
62 | # negative element count to get them sorted from deepest to shallowest branch.
63 | sorted_name_list = sorted(nmspc_children, key=lambda element: -element.count(":"))
64 | for obj in sorted_name_list:
65 | cmds.namespace(removeNamespace=obj, deleteNamespaceContent=True)
66 | cmds.namespace(removeNamespace=namespace, deleteNamespaceContent=True)
67 | return True
68 | except Exception as err:
69 | print(err)
70 | return False
71 |
72 | def get_scene_path(full_path=True, name_only=False, folder_only=False, extension=True):
73 | """
74 | Extension of the normal pm.sceneName() with a bit more options
75 |
76 | :param full_path: *bool* returns the full path (D:/Game/scenes/enemy.ma)
77 | :param name_only: *bool* returns the name of the file only (enemy.ma)
78 | :param folder_only: *bool* returns the folder of the file only (D:/Game/scenes)
79 | :param extension: *bool* whether or not to return the name with the extension
80 | :return: *string*
81 | """
82 | if name_only:
83 | name = os.path.basename(pm.sceneName())
84 | if extension:
85 | return name
86 | return os.path.splitext(name)[0]
87 | if folder_only:
88 | return os.path.dirname(pm.sceneName())
89 | if full_path:
90 | if extension:
91 | return pm.sceneName()
92 | return os.path.splitext(pm.sceneName())[0]
93 | return ""
94 |
95 |
96 | def selection(as_strings=False, as_pynodes=False, st=False, py=False):
97 | """
98 | Convenience function to easily get your selection as a string representation or as pynodes
99 |
100 | :param as_strings: *bool* returns a list of strings
101 | :param as_pynodes: *bool* returns a list of pynodes
102 | :param st: *bool* same as as_strings
103 | :param py: *bool* same as as_pynodes
104 | :return:
105 | """
106 | if as_strings or st:
107 | return [str(uni) for uni in cmds.ls(selection=True)]
108 |
109 | if as_pynodes or py:
110 | return pm.selected()
111 |
112 |
113 | ```
114 |
115 |
116 | Running this code inside Maya sets up a SkyHook server:
117 | ```python
118 | import pprint
119 | import skyhook_commands # this is the file listed above
120 | from skyhook import server as shs
121 | from skyhook.constants import ServerEvents, HostPrograms
122 |
123 | def catch_execution_signal(command, parameters):
124 | """
125 | This function is ran any time the skyhook server executes a command. You can use this as a callback to do
126 | specific things, if needed.
127 | """
128 | print(f"I just ran the command {command}")
129 | print("These were the parameters of the command:")
130 | pprint.pprint(parameters)
131 |
132 | if command == "SKY_SHUTDOWN":
133 | print("The shutdown command has been sent and this server will stop accepting requests")
134 | elif command == "get_from_list":
135 | print("Getting this from a list!")
136 |
137 |
138 | # Since we're running this in Maya, we want to make use of a MainThreadExecutor, so that Maya's code gets executed in the
139 | # main (UI) thread. Otherwise Maya can get unstable and crash depending on what it is we're doing. Therefor we'll use the
140 | # `start_executor_server_in_thread` function, pass along "maya" as a host program so that the proper MainThreadExecutor is
141 | # started.
142 | thread, executor, server = shs.start_executor_server_in_thread(host_program=HostPrograms.maya)
143 | if server:
144 | server.hotload_module("maya_mod") # this gets loaded from the skyhook.modules
145 | server.hotload_module(skyhook_commands, is_skyhook_module=False) # passing False to is_skyhook_module, because this files comes from outside the skyhook package
146 |
147 | server = server
148 | executor = executor
149 | thread = thread
150 |
151 | server.events.connect(ServerEvents.exec_command, catch_execution_signal) # call the function `catch_execution_signal` any time this server executes a command through its MainThreadExecutor
152 | server.events.connect(ServerEvents.command, catch_execution_signal) # call the same function any time this server exectures a command outside of its MainThreadExecutor
153 | ```
154 |
155 | We'll make a `MayaClient` to run any of the functions that we've provided the server with. This can be run as a standalone Python program, or from another piece of software, like Blender or Substance Painter.
156 |
157 | ```python
158 | from skyhook import client
159 |
160 | maya_client = client.MayaClient()
161 |
162 | result = maya_client.execute("selection", {"st": True})
163 | print(f"The current selection is: {result.get('ReturnValue', None)}" )
164 | # >> The current selection is: ['actor0:Leg_ctrl.cv[14]', 'actor0:Chest_ctrl', 'actor0:bake:root']
165 |
166 | result = maya_client.execute("get_scene_path")
167 | print(f"The current scene path is: {result.get('ReturnValue', '')}")
168 | # >> The current scene path is: D:/THEFINALS/MayaScenes/Weapons/AKM/AKM.ma
169 | ```
170 |
171 |
172 | ## SkyHook server in Unreal
173 |
174 | Unreal is a bit of a different beast. It does support Python for editor related tasks, but seems to be starving any threads pretty quickly. That's why it's pretty much impossible to run the SkyHook server in Unreal like we're able to do so in other programs. However, as explained in the main outline, SkyHook clients don't have to necessarily connect to SkyHook servers. That's why we can use ``skyhook.client.UnrealClient`` with Unreal Engine's built-in Remote Control API.
175 |
176 | Make sure the Remote Control API is loaded from `Edit > Plugins`
177 |
178 | 
179 |
180 | Loading a SkyHook module in Unreal is done by just importing it like normal. Assuming the code for the SkyHook module is in a file called `skyhook_commands` in the Python folder (`/Game/Content/Python`), you can just do:
181 |
182 |
183 | ```python
184 | import skyhook_commands
185 | ```
186 |
187 | If you want to make sure it's always available when you start the engine, add an `init_unreal.py` file in the Python folder and import the module from there. I've had problems with having a `__pycache__` folder on engine start up, so I just deleted it from that some `init_unreal.py` file:
188 |
189 |
190 | ```python
191 | # making sure skyhook_commands are loaded and ready to go
192 | import skyhook_commands
193 | # adding this here because it's a pain in the ass to retype every time
194 | from importlib import reload
195 |
196 | # cleaning up pycache folder
197 | pycache_folder = os.path.join(os.path.dirname(__file__), "__pycache__")
198 | if os.path.isdir(pycache_folder):
199 | shutil.rmtree(pycache_folder)
200 | ```
201 |
202 | ## The SkyHook Unreal module
203 |
204 | Unreal has specific requirements to run Python code. So you have to keep that in mind when adding functionality to the SkyHook module. In order for it to be "seen" in Unreal you need to decorate your class and functions with specific Unreal decorators.
205 |
206 | Decorating the class with `unreal.uclass()`:
207 |
208 | ```python
209 | import unreal
210 |
211 | @unreal.uclass()
212 | class SkyHookCommands(unreal.BlueprintFunctionLibrary):
213 | def __init__(self):
214 | super(SkyHookCommands, self).__init__()
215 | ```
216 |
217 | If you want your function to accept parameters, you need to add them in the decorator like this
218 |
219 | ```python
220 | import unreal
221 | import os
222 |
223 | @unreal.ufunction(params=[str, str], static=true)
224 | def rename_asset(asset_path, new_name):
225 | dirname = os.path.dirname(asset_path)
226 | new_name = dirname + "/" + new_name
227 | unreal.EditorAssetLibrary.rename_asset(asset_path, new_name)
228 | unreal.log_warning("Renamed to %s" % new_name)
229 | ```
230 | ---
231 | **NOTE**
232 |
233 | You can not use Python's `list` in the decorator for the Unreal functions. Use `unreal.Array(type)`, eg: `unreal.Array(float)`.
234 |
235 | ---
236 |
237 | ---
238 | **NOTE**
239 |
240 | You can not use Python's `dict` in the decorator for the Unreal functions. Use `unreal.Map(key type, value type)`, eg: `unreal.Map(str, int)
241 |
242 | ---
243 |
244 |
245 | ## Returning values to the SkyHook client
246 |
247 | Whenever you want to return anything from Unreal back to your SkyHook client, add the `ret` keyword in the function parameters:
248 | ```python
249 | @unreal.ufunction(params=[str], ret=bool, static=True)
250 | def does_asset_exist(asset_path=""):
251 | """
252 | Return True or False whether or not asset exists at given path
253 |
254 | :return: *bool*
255 | """
256 | return_value = unreal.EditorAssetLibrary.does_asset_exist(asset_path)
257 |
258 | return return_value
259 | ```
260 |
261 |
262 | ## Returning "complex" items from Unreal
263 |
264 | Let's say you want to return a dictionary from Unreal that looks like this:
265 |
266 | ```python
267 | my_dict = {
268 | "player_name": "Nisse",
269 | "player_health": 98.5,
270 | "player_lives": 3,
271 | "is_player_alive": True
272 | }
273 | ```
274 |
275 | Unfortunately, due to how Unreal works, you can't specify your return value as a dictionary or list with mixed values. This can be quite problematic because sometimes that's exactly what you want. To solve this you can just stringify your return value and it will be decoded into a Python object on the client side.
276 |
277 | So in the example above, you'd do something like
278 |
279 | ```python
280 | @unreal.ufunction(params=[str], ret=str, static=True)
281 | def my_cool_function(player_name=""):
282 | my_dict = {
283 | "player_name": "Nisse",
284 | "player_health": 98.5,
285 | "player_lives": 3,
286 | "is_player_alive": True
287 | }
288 |
289 | return str(my_dict)
290 | ```
291 |
292 | Since "this will just work", you can basically always just return a string like that.
293 |
294 |
295 | ## Complete example of a `skyhook_commands.py` file in Unreal
296 |
297 | Here's a short snippet of a `skyhook_commands.py` to help you get on your way
298 |
299 | ```python
300 | import unreal
301 |
302 | import os
303 | import pathlib
304 |
305 | @unreal.uclass()
306 | class SkyHookCommands(unreal.BlueprintFunctionLibrary):
307 |
308 | @unreal.ufunction(params=[str], ret=str, static=True)
309 | def create_control_rig_from_skeletal_mesh(skeletal_mesh_path):
310 | skeletal_mesh = unreal.EditorAssetLibrary.load_asset(skeletal_mesh_path)
311 |
312 | # make the blueprint using a the factory
313 | ctrl_rig_blueprint: unreal.ControlRigBlueprint = unreal.ControlRigBlueprintFactory.create_control_rig_from_skeletal_mesh_or_skeleton(selected_object=skeletal_mesh)
314 |
315 | # chuck in a begin execution node
316 | controller: unreal.RigVMController = ctrl_rig_blueprint.get_controller_by_name("RigVMModel")
317 | controller.add_unit_node_from_struct_path(script_struct_path=unreal.RigUnit_BeginExecution.static_struct(),
318 | position=unreal.Vector2D(0.0, 0.0),
319 | node_name="RigUnit_BeginExecution")
320 |
321 | # return the path of the blueprint we just made
322 | return ctrl_rig_blueprint.get_path_name()
323 |
324 |
325 | @unreal.ufunction(params=[str, str, str, bool, bool, int, bool], ret=bool, static=True)
326 | def import_animation(source_path, destination_path, unreal_skeleton_path, replace_existing, save_on_import, sample_rate, show_dialog):
327 | destination_path = match_existing_folder_case(destination_path)
328 |
329 | options = unreal.FbxImportUI()
330 |
331 | options.import_animations = True
332 | options.skeleton = unreal.load_asset(unreal_skeleton_path)
333 | options.anim_sequence_import_data.set_editor_property("animation_length", unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME)
334 | options.anim_sequence_import_data.set_editor_property("remove_redundant_keys", False)
335 | options.anim_sequence_import_data.set_editor_property("custom_sample_rate", sample_rate)
336 | unreal.log_warning(f"Imported using sample rate: {sample_rate}")
337 | # options.anim_sequence_import_data.set_editor_property("use_default_sample_rate", True)
338 |
339 | task = unreal.AssetImportTask()
340 | task.automated = not show_dialog
341 | task.destination_path = destination_path
342 | task.filename = source_path
343 | task.replace_existing = replace_existing
344 | task.save = save_on_import
345 | task.options = options
346 |
347 | unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
348 |
349 | return True
350 |
351 | @unreal.ufunction(params=[str, str, str], ret=str, static=True)
352 | def create_material_instance(destination_path="", material_name="new_material", parent_path=""):
353 | """
354 | Create a material instance at the desetination with the provided name and parent material
355 |
356 | :return: *str* asset path of material instance
357 | """
358 | # Get asset tools
359 | destination_path = match_existing_folder_case(destination_path)
360 | assetTools = unreal.AssetToolsHelpers.get_asset_tools()
361 |
362 | if unreal.EditorAssetLibrary.does_asset_exist(parent_path):
363 | parent_material = unreal.load_asset(parent_path)
364 | else:
365 | print("Could not find parent material " + parent_path)
366 | return ""
367 |
368 | material_instance = assetTools.create_asset(asset_name=material_name, package_path=destination_path, asset_class=unreal.MaterialInstanceConstant, factory=unreal.MaterialInstanceConstantFactoryNew())
369 | unreal.MaterialEditingLibrary().set_material_instance_parent(material_instance, parent_material)
370 |
371 | return material_instance.get_path_name()
372 |
373 | @unreal.ufunction(params=[str], ret=bool, static=True)
374 | def load_map(filename=""):
375 | """
376 | Set the position and rotation of the viewport camera
377 |
378 | :return: *bool* true if the action is completed
379 | """
380 |
381 | # Get path for currently loaded map
382 | current_map = unreal.EditorLevelLibrary.get_editor_world().get_path_name()
383 |
384 | # If the requested map is not loaded, prompt before loading
385 | if filename != current_map:
386 | if show_dialog_confirm("Skyhook Load Map", "Are you sure you want to load this map? Any changes to the current map will be lost!") == unreal.AppReturnType.YES:
387 | unreal.EditorLoadingAndSavingUtils.load_map(filename)
388 | else:
389 | return False
390 |
391 | return True
392 |
393 | @unreal.ufunction(params=[str, unreal.Array(str)], ret=bool, static=True)
394 | def load_map_and_select_actors(filename="", actor_guids=[]):
395 | """
396 | Set the position and rotation of the viewport camera
397 |
398 | :return: *bool* true if the action is completed
399 | """
400 |
401 | # Get path for currently loaded map
402 | current_map = unreal.EditorLevelLibrary.get_editor_world().get_path_name()
403 |
404 | # If the requested map is not loaded, prompt before loading
405 | if filename != current_map:
406 | if show_dialog_confirm("Skyhook Load Map", "Are you sure you want to load this map? Any changes to the current map will be lost!") == unreal.AppReturnType.YES:
407 | unreal.EditorLoadingAndSavingUtils.load_map(filename)
408 | else:
409 | return False
410 |
411 | guids = []
412 |
413 | # Parse guid string to guid struct
414 | for actor_guid in actor_guids:
415 | guids.append(unreal.GuidLibrary.parse_string_to_guid(actor_guid)[0])
416 |
417 | # Create actor list (STUPID WAY, BUT SEEMS TO BE NO OTHER CHOICE)
418 | # Get all actors in level
419 | level_actors = unreal.EditorLevelLibrary.get_all_level_actors()
420 | actors = []
421 |
422 | # Compare actor guids
423 | for level_actor in level_actors:
424 | for guid in guids:
425 | if unreal.GuidLibrary.equal_equal_guid_guid(level_actor.get_editor_property('actor_guid'), guid):
426 | actors.append(level_actor)
427 | break
428 |
429 | # Select actors
430 | unreal.EditorLevelLibrary.set_selected_level_actors(actors)
431 |
432 | return True
433 |
434 | @unreal.ufunction(params=[str, unreal.Array(str), bool], ret=str, static=True)
435 | def search_for_asset(root_folder="/Game/", filters=[], search_complete_path=True):
436 | """
437 | Returns a list of all the assets that include the file filters
438 |
439 | :return: *list*
440 | """
441 | filtered_assets = []
442 |
443 | assets = unreal.EditorAssetLibrary.list_assets(root_folder)
444 |
445 | assets = [path for path in assets if path.startswith(root_folder)]
446 |
447 | for asset in assets:
448 | for file_filter in filters:
449 | if not search_complete_path:
450 | if file_filter.lower() in os.path.basename(asset).lower():
451 | filtered_assets.append(asset)
452 | else:
453 | if file_filter.lower() in asset.lower():
454 | filtered_assets.append(asset)
455 |
456 | return str(filtered_assets)
457 |
458 |
459 | """
460 | General helper functions
461 | """
462 |
463 | def match_existing_folder_case(input_path="/Game/developers"):
464 | """
465 | Match the case sensitivity of existing folders on disk, so we don't submit perforce paths with mismatched casing
466 | """
467 | input_path = input_path.replace("\\", "/")
468 |
469 | project_content_path = unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_content_dir())
470 | project_disk_path = pathlib.Path(project_content_path).resolve().as_posix() + "/"
471 |
472 | disk_path = input_path.replace("/Game/", project_disk_path)
473 |
474 | # this is the magic line that makes it match the local disk
475 | case_sensitive_path = pathlib.Path(disk_path).resolve().as_posix()
476 |
477 | matched_path = case_sensitive_path.replace(project_disk_path, "/Game/")
478 |
479 | # make sure trailing slashes is preserved
480 | if input_path.endswith("/"):
481 | matched_path = f"{matched_path}/"
482 |
483 | if matched_path != input_path:
484 | print(f"Incorrectly cased path '{input_path}' was converted to '{matched_path}'")
485 |
486 | return matched_path
487 |
488 | def show_dialog_confirm(title, message):
489 | """
490 | Show a dialog with YES/NO options.
491 |
492 | :return: *bool* true/false depending on which button is clicked
493 | """
494 | message_type = unreal.AppMsgType.YES_NO
495 | return_value = unreal.EditorDialog.show_message(title, message, message_type)
496 |
497 | return return_value
498 |
499 | ```
500 |
501 | # Unreal Client
502 |
503 | A SkyHook client for Unreal works exactly the same as for a normal SkyHook server.
504 |
505 | ```python
506 | from skyhook import client
507 |
508 | client = client.UnrealClient()
509 |
510 | result = client.execute("search_for_asset", {"root_folder": "/Game", "filters": ["SK_Char"]})
511 | print(result.get("ReturnValue", None))
512 |
513 | # >> [/Game/ArcRaiders/Characters/SK_Character01.SK_Character01, /Game/ArcRaiders/Characters/SK_Character02.SK_Character02, /Game/ArcRaiders/Props/SK_CharredHusk.SK_CharredHusk]
514 | ```
515 |
516 | ---
517 | **NOTE**
518 |
519 | Due to how Unreal processes Python commands, you can't skip an argument or keyword argument when calling a function. In the example above, doing something like this will cause an error in Unreal
520 |
521 | ```python
522 | client.execute("search_for_asset", {"filters": ["SK_Char"]}) # not passing the first keyword argument (`root_folder`)
523 | # -> errors out in Unreal
524 | ```
525 |
526 | ---
527 |
528 |
529 | ## Contributing
530 |
531 | [](../main/CODE_OF_CONDUCT.md)
532 |
533 | We welcome community contributions to this project.
534 |
535 | Please read our [Contributor Guide](CONTRIBUTING.md) for more information on how to get started.
536 |
537 | ## License
538 |
539 | Licensed under either of
540 |
541 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
542 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
543 |
544 | at your option.
545 |
546 | ### Contribution
547 |
548 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
549 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = source
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=source
11 | set BUILDDIR=build
12 |
13 | if "%1" == "" goto help
14 |
15 | %SPHINXBUILD% >NUL 2>NUL
16 | if errorlevel 9009 (
17 | echo.
18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
19 | echo.installed, then set the SPHINXBUILD environment variable to point
20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
21 | echo.may add the Sphinx directory to PATH.
22 | echo.
23 | echo.If you don't have Sphinx installed, grab it from
24 | echo.http://sphinx-doc.org/
25 | exit /b 1
26 | )
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/docs/source/client.rst:
--------------------------------------------------------------------------------
1 | SkyHook Client
2 | ====================
3 | The client uses Python's ``request`` library to send a POST request to the server. The ``Client`` class is built in such a way that it looks practically identical to use with a SkyHook server or Unreal's Web Remote Control. Behind the scenes there's some extra work done to make it all work. So if you're reading this and you use want to use the ``Client`` as is, great! If you want to tinker with the code a bit yourself, be aware that the ``UnrealClient`` needs some TLC :)
4 |
5 | Making a client
6 | ----------------------
7 |
8 | A SkyHook client only needs to be provided with an address (default is 127.0.0.1) and a port (default is 65500) to connect to.
9 |
10 | Making a SkyHook ``Client`` object is as simple as:
11 |
12 | .. code-block:: python
13 |
14 | import skyhook.client
15 |
16 | client = skyhook.client.Client()
17 |
18 | ``skyhook.constants.Ports`` holds the ports that are set up to use with host programs by default. You can use this if you don't remember which program uses which port.
19 |
20 | .. code-block:: python
21 |
22 | import skyhook.client
23 | from skyhook.constants import Ports
24 |
25 | client = skyhook.client.Client()
26 | client.port = Ports.houdini
27 |
28 |
29 | DCC specific client
30 | ---------------------
31 |
32 | All DCCs have their own named client you can use to make your life easier.
33 |
34 | .. code-block:: python
35 |
36 | import skyhook.client
37 |
38 | client = skyhook.client.BlenderClient()
39 |
40 | That way you don't have to worry about which port to use.
41 |
42 | All DCC clients are basically the same as the base ``Client`` class they inherit from. Unreal is the only odd man out, more on that further down.
43 |
44 | Executing functions on the server
45 | -----------------------------------
46 |
47 | You use the ``execute`` function send a command to the server. It takes a ``command`` argument and an optional ``parameters`` argument. The ``command`` can be a string, or an imported module. Eg:
48 |
49 | .. code-block:: python
50 |
51 | import skyhook.client
52 | import skyhook.modules.blender as blender_module
53 |
54 | client = skyhook.client.BlenderClient()
55 | client.execute("make_cube")
56 | client.execute(blender_module.export_usd, parameters={"path": "D:/test_file.usda"})
57 |
58 | The ``parameters`` always have to be a dictionary and the keys have to match the argument names used in the function. If you want to execute a function on a SkyHook server without having access to the code, you can use the ``SKY_FUNCTION_HELP`` command to see what the arguments or keywords are called. For example, the function ``echo_message`` expects an argument called ``message``:
59 |
60 | .. code-block:: python
61 |
62 | import skyhook.client
63 | from skyhook.constants import ServerCommands, Ports
64 |
65 | client = skyhook.client.Client()
66 | client.execute(ServerCommands.SKY_FUNCTION_HELP, parameters={"function_name": "echo_message"})
67 |
68 | Returns:
69 |
70 | .. code-block:: python
71 |
72 | {
73 | u'Command': u'SKY_FUNCTION_HELP',
74 | u'ReturnValue': {u'arguments': [u'message'],
75 | u'function_name': u'echo_message'},
76 | u'Success': True,
77 | u'Time': u'19:15:14'
78 | }
79 |
80 |
81 | Adding a module to the server using the client
82 | -----------------------------------
83 |
84 | One of the ``ServerCommands`` is ``SKY_HOTLOAD``. You can use this to add functionality to the server from the client.
85 |
86 | .. code-block:: python
87 |
88 | import skyhook.client
89 | import skyhook.modules.maya as maya
90 | from skyhook import ServerCommands
91 |
92 | maya_client = skyhook.client.MayaClient()
93 | maya_client.execute(ServerCommands.SKY_HOTLOAD, parameters={"modules": ["embark_tools.Core.maya_utils"], "is_skyhook_module": False})
94 |
95 | This will bring in all the functions from those modules.
96 |
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
6 |
7 | # -- Path setup --------------------------------------------------------------
8 |
9 | # If extensions (or modules to document with autodoc) are in another directory,
10 | # add these directories to sys.path here. If the directory is relative to the
11 | # documentation root, use os.path.abspath to make it absolute, like shown here.
12 | #
13 | import os
14 | import sys
15 | sys.path.insert(0, os.path.abspath('../../skyhook'))
16 |
17 |
18 | # -- Project information -----------------------------------------------------
19 |
20 | project = 'SkyHook'
21 | copyright = '2021, Niels Vaes'
22 | author = 'Niels Vaes'
23 |
24 | # The full version, including alpha/beta/rc tags
25 | release = '0.0.1'
26 |
27 |
28 | # -- General configuration ---------------------------------------------------
29 |
30 | # Add any Sphinx extension module names here, as strings. They can be
31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
32 | # ones.
33 | extensions = [
34 | "sphinx.ext.autodoc",
35 | "sphinx_rtd_theme",
36 | "recommonmark" # for markdown
37 | ]
38 |
39 | source_suffix = {
40 | '.rst': 'restructuredtext',
41 | '.txt': 'restructuredtext',
42 | '.md': 'markdown',
43 | }
44 |
45 |
46 |
47 | # Add any paths that contain templates here, relative to this directory.
48 | templates_path = ['_templates']
49 |
50 | # List of patterns, relative to source directory, that match files and
51 | # directories to ignore when looking for source files.
52 | # This pattern also affects html_static_path and html_extra_path.
53 | exclude_patterns = []
54 |
55 |
56 | # -- Options for HTML output -------------------------------------------------
57 | import sphinx_rtd_theme
58 | # The theme to use for HTML and HTML Help pages. See the documentation for
59 | # a list of builtin themes.
60 | #
61 | html_theme = 'sphinx_rtd_theme'
62 |
63 | # Add any paths that contain custom static files (such as style sheets) here,
64 | # relative to this directory. They are copied after the builtin static files,
65 | # so a file named "default.css" will overwrite the builtin "default.css".
66 | html_static_path = ['_static']
--------------------------------------------------------------------------------
/docs/source/general.rst:
--------------------------------------------------------------------------------
1 | General
2 | ========
3 |
4 | Constants module
5 | ------------------
6 | The constants module is where SkyHook's constants are stored. This is the place where you would add constants for new default host program or server commands for example.
7 |
8 | The ``Constants`` class in the ``constants`` module is generally where I put things that will make life easier writing code, because you can auto complete to these.
9 |
10 | .. code-block:: python
11 |
12 | class Constants:
13 | """
14 | Some very general constants that are used throughout SkyHook
15 | """
16 | function_name = "FunctionName"
17 | parameters = "Parameters"
18 | module = "_Module"
19 | undefined = "undefined"
20 |
21 |
22 | The SkyHook server comes with a couple of commands that are not executed from a module, but on the server itself. These are described in
23 | ``skyhook.constants.ServerCommands``. They always start with "SKY", so don't add any functions with names like this to any module you load into the server.
24 |
25 | .. code-block:: python
26 |
27 | class ServerCommands:
28 | SKY_SHUTDOWN = "SKY_SHUTDOWN"
29 | SKY_LS = "SKY_LS"
30 | SKY_RELOAD_MODULES = "SKY_RELOAD_MODULES"
31 | SKY_HOTLOAD = "SKY_HOTLOAD"
32 | SKY_UNLOAD = "SKY_UNLOAD"
33 | SKY_FUNCTION_HELP = "SKY_FUNCTION_HELP"
34 |
35 | * SKY_SHUTDOWN: Shuts down the server
36 | * SKY_LS: Returns a list with all the function names this server can execute
37 | * SKY_RELOAD_MODULES: Remotely reloads the loaded modules
38 | * SKY_HOTLOAD: needs to be called with the ``skyhook.Constants.module`` argument as a list of module names in the ``parameters``, as explained above.
39 | * SKY_UNLOAD: needs to be called with the ``skyhook.Constants.module`` argument as a list of module names in the ``parameters``, these modules will be removed from the server.
40 | * SKY_FUNCTION_HELP: needs to be called with "function_name" argument as string in the ``parameters``, this will return the names of the arguments, packed arguments and packed keywords a function requires.
41 |
42 |
43 | Their functionality is hardcoded in ``__process_server_command()`` in the ``Server`` class. If you want to add any extra functionality, this is where you do it.
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | Welcome to SkyHook's documentation!
2 | ===================================
3 |
4 | SkyHook was created to facilitate communication between DCCs, standalone applications, web browsers and game engines. As of right now, it's working in Houdini, Blender, Maya and Unreal Engine.
5 |
6 | .. image:: ../../wiki-images/UE_Logo_Icon_Black.png
7 | :width: 20%
8 | .. image:: ../../wiki-images/blender_logo.png
9 | :width: 20%
10 | .. image:: ../../wiki-images/houdinibadge.png
11 | :width: 20%
12 | .. image:: ../../wiki-images/maya_logo.png
13 | :width: 20%
14 |
15 |
16 |
17 | It works both with Python 2.7.x and 3.x and you don't need to have the same Python version across programs. For example, you can build a standalone application in Python 3.8.5 and use SkyHook to communicate with Maya's Python 2.7. This makes it much easier to use than something like RPyC where even a minor version difference can stop it from working.
18 |
19 | SkyHook consist of 2 parts that can, but don't have to, work together. There's a client and a server. The server is just a very simple HTTP server that takes JSON requests. It parses those requests and tries to call the function that was passed in the request. It returns another JSON dictionary with the result of the outcome of the function.
20 |
21 | The client just makes a a POST request to the server with a JSON payload. This is why you can basically use anything that's able to do so as a client. Could be in a language other than Python, or even just webpage or mobile application. In the future this SkyHook should also support `websockets` connections
22 |
23 |
24 |
25 | .. toctree::
26 | :maxdepth: 3
27 | :caption: Contents:
28 |
29 | general
30 | server
31 | server_functionality
32 | server_commands
33 | client
34 | unreal_engine_server
35 | unreal_engine_client
36 |
37 |
38 |
39 | Indices and tables
40 | ==================
41 |
42 | * :ref:`genindex`
43 | * :ref:`modindex`
44 | * :ref:`search`
45 |
--------------------------------------------------------------------------------
/docs/source/modules.rst:
--------------------------------------------------------------------------------
1 | skyhook
2 | =======
3 |
4 | .. toctree::
5 | :maxdepth: 4
6 |
7 | skyhook
8 |
--------------------------------------------------------------------------------
/docs/source/nisse.md:
--------------------------------------------------------------------------------
1 | # SkyHook
2 |
3 | SkyHook was created to facilitate communication between DCCs, standalone applications, web browsers and game engines. As of right now, it's working in Houdini, Blender, Maya and Unreal Engine.
4 |
5 |
6 |
7 |  |
8 |  |
9 |  |
10 |  |
11 |
12 |
13 |
14 | It works both with Python 2.7.x and 3.x and you don't need to have the same Python version across programs. For example, you can build a standalone application in Python 3.8.5 and use SkyHook to communicate with Maya's Python 2.7. This makes it much easier to use than something like RPyC where even a minor version difference can stop it from working.
15 |
16 | SkyHook consist of 2 parts that can, but don't have to, work together. There's a client and a server. The server is just a very simple HTTP server that takes JSON requests. It parses those requests and tries to call the function that was passed in the request. It returns another JSON dictionary with the result of the outcome of the function.
17 |
18 | The client just makes a a POST request to the server with a JSON payload. This is why you can basically use anything that's able to do so as a client. Could be in a language other than Python, or even just webpage or mobile application. In the future this SkyHook should also support `websockets` connections
19 |
--------------------------------------------------------------------------------
/docs/source/server.rst:
--------------------------------------------------------------------------------
1 | SkyHook Server
2 | ===============
3 |
4 | The server is built on Python's ``wsgiref.simple_server`` and PyQt5/PySide2. Once it's started, it enters an infinite loop where it continues to take requests until its ``__keep_running`` boolean is set to False.
5 | Depending on where you're using the server, you might use different ways to start it. Also, every host program has its own predefined port to which the server binds itself. This can be overridden if you want (``Server.port``) This could potentially be useful if you have both a GUI and a headless version of a program running.
6 |
7 |
8 | Maya and Houdini
9 | -----------------
10 |
11 | Maya and Houdini will happily execute any Python code in a different thread without any (added) stability issues (please link to this page when you prove me wrong :)). As such, we can launch the server by making a SkyHook server object, making a QThread object, linking the two together and starting the ``run`` function on the thread:
12 |
13 | .. code-block:: python
14 |
15 | from skyhook.server import Server
16 | skyhook_server = Server(host_program="maya", load_modules=["maya"])
17 | thread_object = QThread()
18 | skyhook_server.is_terminated.connect(partial(__kill_thread, thread_object))
19 | skyhook_server.moveToThread(thread_object)
20 |
21 | thread_object.started.connect(skyhook_server.start_listening)
22 | thread_object.start()
23 |
24 | Or, you can use the ``start_server_in_thread`` function from ``skyhook.server``:
25 |
26 | .. code-block:: python
27 |
28 | import skyhook.server as shs
29 | from skyhook.constants import HostPrograms
30 | thread, server = shs.start_server_in_thread(host_program=HostPrograms.houdini, load_modules=["houdini"])
31 |
32 | It returns the ``QThread`` and the ``Server`` object.
33 |
34 | Blender
35 | --------------
36 | Blender is a bit finicky when it comes to executing anything from ``bpy.ops`` in a thread different than the main program thread. Because of this, we have to make an object that gets instructions from the SkyHook server whenever the server handles a request. This object is called a ``MainThreadExecutor`` and lives in the ``skyhook.server`` module. While the SkyHook server runs in a separate thread, the ``MainThreadExecutor`` (as the name suggests) runs in Blender's main thread. We pass information from the server to the main thread so it can safely be executed there.
37 |
38 | Adding functionality to the ``blender`` module also needs some extra care.
39 |
40 | You can run construct the server like this:
41 |
42 | .. code-block:: python
43 |
44 | import skyhook.server as shs
45 | from skyhook.constants import HostPrograms
46 |
47 | skyhook_server = Server(host_program=HostPrograms.blender, load_modules=["blender"], use_main_thread_executor=True)
48 | executor = MainThreadExecutor(skyhook_server)
49 | skyhook_server.exec_command_signal.connect(executor.execute)
50 |
51 | thread_object = QThread()
52 | skyhook_server.moveToThread(thread_object)
53 | thread_object.started.connect(skyhook_server.start_listening)
54 | thread_object.start()
55 |
56 | Or, use this one-liner:
57 |
58 | .. code-block:: python
59 |
60 | import skyhook.server as shs
61 | from skyhook.constants import HostPrograms
62 | thread, executor, server = shs.start_executor_server_in_thread(host_program=HostPrograms.blender, load_modules=["blender"])
63 |
64 | Blender as a whole is a bit weirder when it comes to running a Qt Application and needs some boiler plate code to do so. I found that the following code is able to run the server without much problems:
65 |
66 | .. code-block:: python
67 |
68 | # Clear the default system console
69 | print("\n" * 60)
70 |
71 | import bpy
72 |
73 | import sys
74 | import subprocess
75 | import pkg_resources
76 |
77 | import traceback
78 |
79 | # Blender needs these packages to run skyhook
80 | required = {"PySide2", "requests", "git+https://github.com/EmbarkStudios/skyhook"}
81 |
82 | # Let's find the missing packages
83 | installed = {pkg.key for pkg in pkg_resources.working_set}
84 | missing = required - installed
85 |
86 | # and if there are any, pip install them
87 | if missing:
88 | python = sys.executable
89 | subprocess.check_call([python, "-m", "pip", "install", *missing], stdout=subprocess.DEVNULL)
90 |
91 | try:
92 | from PySide2.QtCore import *
93 | from PySide2.QtWidgets import *
94 | from PySide2.QtGui import *
95 | except Exception:
96 | print("Couldn't install PySide2")
97 |
98 | class SkyhookServer():
99 | def __init__(self):
100 | self.thread, self.executor, self.server = \
101 | server.start_executor_server_in_thread(host_program=HostPrograms.blender,
102 | load_modules=[blender])
103 | self.__app = QApplication.instance()
104 | if not self.__app:
105 | self.__app = QApplication(["blender"])
106 | if self.__app:
107 | self.__app.processEvents()
108 |
109 | if __name__ == "__main__":
110 | try:
111 | import skyhook
112 | from skyhook import HostPrograms
113 | from skyhook.modules import blender
114 | from skyhook import server
115 |
116 | bpy.types.Scene.skyhook_server = SkyhookServer()
117 |
118 | except Exception as err:
119 | print(str(traceback.format_exc()))
120 | print(str(err))
121 |
122 |
123 | Standalone
124 | --------------
125 |
126 | SkyHook doesn't necessarily need to run inside a host program. It can be a stand alone application as well that you can dump on machine somewhere to control. In this case, you'll most likely won't have to worry about running it in a separate thread. You can start it like this, just from a Python prompt:
127 |
128 | .. code-block:: python
129 |
130 | import skyhook.server as shs
131 | from skyhook.constants import HostPrograms
132 |
133 | skyhook_server = Server()
134 | skyhook_server.start_listening()
135 |
136 | Since we're not supplying it with a host program, it will bind itself to ``skyhook.constants.Ports.undefined`` (65500). Unless you add the ``load_modules`` argument when you make the ``Server``, the functionality will come from ``skyhook.modules.core``.
137 |
138 | The one-liner:
139 |
140 | .. code-block:: python
141 |
142 | import skyhook.server as shs
143 | shs.start_blocking_server()
144 |
145 |
146 | I'm actually using it to connect to a Raspberry Pi that controls my Christmas lights 😄 🎄
147 |
148 |
--------------------------------------------------------------------------------
/docs/source/server_commands.rst:
--------------------------------------------------------------------------------
1 | Server commands
2 | ================
3 |
4 | The SkyHook server comes with a couple of commands that are not executed from a module, but on the server itself. These are described in
5 | ``skyhook.constants.ServerCommands``. They always start with "SKY", so don't add any functions with names like this to any module you load into the server.
6 |
7 | .. code-block:: python
8 |
9 | class ServerCommands:
10 | SKY_SHUTDOWN = "SKY_SHUTDOWN"
11 | SKY_LS = "SKY_LS"
12 | SKY_RELOAD_MODULES = "SKY_RELOAD_MODULES"
13 | SKY_HOTLOAD = "SKY_HOTLOAD"
14 | SKY_UNLOAD = "SKY_UNLOAD"
15 | SKY_FUNCTION_HELP = "SKY_FUNCTION_HELP"
16 |
17 | * SKY_SHUTDOWN: Shuts down the server
18 | * SKY_LS: Returns a list with all the function names this server can execute
19 | * SKY_RELOAD_MODULES: Remotely reloads the loaded modules
20 | * SKY_HOTLOAD: needs to be called with the ``skyhook.Constants.module`` argument as a list of module names in the ``parameters``, as explained above.
21 | * SKY_UNLOAD: needs to be called with the ``skyhook.Constants.module`` argument as a list of module names in the ``parameters``, these modules will be removed from the server.
22 | * SKY_FUNCTION_HELP: needs to be called with "function_name" argument as string in the ``parameters``, this will return the names of the arguments, packed arguments and packed keywords a function requires.
23 |
24 |
25 | Their functionality is hardcoded in ``__process_server_command()`` in the ``Server`` class. If you want to add any extra functionality, this is where you do it.
--------------------------------------------------------------------------------
/docs/source/server_functionality.rst:
--------------------------------------------------------------------------------
1 | Server functionality
2 | =====================
3 |
4 |
5 | The server itself doesn't really do much. Any functionality needs to come from a SkyHook module that either gets loaded when the server is started or hotloaded when the server is running. Modules should be split between different host programs and should ideally be placed in the ``skyhook.modules`` folder.
6 |
7 | * You can load these modules either by name (as a ``string``) or as a Python ``module`` object.
8 | * You can load them when constructing the ``Server`` object, hotload them using the ``Server`` object or remotely using the ``SKY_HOTLOAD`` server command.
9 |
10 | To load the modules when constructing the server, you just pass a list to the ``load_modules`` argument. The following example shows how to load the ``houdini`` module as a string and the ``maya`` module as a ``module`` object. These can be mixed if you want to.
11 |
12 | .. code-block:: python
13 |
14 | import skyhook.server
15 | # import the maya module to then pass into the load_modules argument
16 | import skyhook.modules.maya as maya_module
17 | from skyhook import HostPrograms
18 |
19 | thread, server = skyhook.server.start_server_in_thread(host_program=HostPrograms.houdini, load_modules=["houdini", maya_module])
20 |
21 | If you don't want to restart the server and have access to the ``Server`` object, you can invoke the ``hotload_module()`` function to add a module:
22 |
23 | .. code-block:: python
24 |
25 | import skyhook.server
26 | import skyhook.modules.maya as maya_module
27 | from skyhook import HostPrograms
28 |
29 | thread, server = skyhook.server.start_server_in_thread(host_program=HostPrograms.houdini)
30 | # server is running, now hotload some modules
31 | server.hotload_module("houdini")
32 | server.hotload_module(maya_module)
33 |
34 |
35 | If you don't have access to the ``Server`` object, you can also use the server command ``SKY_HOTLOAD`` to remotely tell the server to add a new module. An example coud look like this:
36 |
37 | .. code-block:: python
38 |
39 | import skyhook.client
40 | from skyhook import ServerCommands
41 |
42 | maya_client = skyhook.client.MayaClient()
43 | maya_client.execute(ServerCommands.SKY_HOTLOAD, parameters={"modules": ["maya"]})
44 |
45 |
46 | Adding functionality to SkyHook from elsewhere
47 | -------------------------------------------------
48 | The examples above all assume that whatever module you want to load is located in the ``skyhook.modules`` folder. It is possible to load modules from elsewhere if you so fancy. This is done using by setting the ``is_skyhook_module`` to ``False`` in the ``hotload_module()`` function on ``Server``.
49 |
50 | Example:
51 |
52 | .. code-block:: python
53 |
54 | import skyhook.server
55 | import skyhook.modules.maya as maya_module
56 | import embark_tools.Core.houdini_utils as houdini_utils
57 | from skyhooks import HostPrograms
58 |
59 | thread, server = skyhook.server.start_server_in_thread(host_program=HostPrograms.houdini)
60 | server.hotload_module("embark_tools.Core.maya_utils", is_skyhook_module=False)
61 | server.hotload_module(houdini_utils, is_skyhook_module=False)
62 |
63 |
64 | .. warning::
65 |
66 | Anything sent to the server remotely is in JSON form. Meaning that if it can't be serialized, the SkyHook server won't know what to do with it. So doing something like this will fail:
67 |
68 | .. code-block:: python
69 |
70 | import skyhook.client
71 | import skyhook.modules.maya as maya_module
72 | from skyhookimport ServerCommands
73 |
74 | maya_client = skyhook.client.MayaClient()
75 | # send the module as a Python module instead of a string
76 | maya_client.execute(ServerCommands.SKY_HOTLOAD, parameters={"modules": [maya_module], "is_skyhook_module": False})
77 |
78 |
79 | Function naming clashes
80 | ------------------------
81 |
82 | By default, the server will look through all its loaded modules to find the function that's being called by the client. This means that if you have modules that both have a function with the same name, the server might execute the function from the wrong module. You can set ``module_name`` in the function ``get_function_by_name`` to specify a specific module to search in. If you want to search in a SkyHook module, just passing the name of the module will work. However, if you've loaded a module from outside SkyHook, you have to provide the complete module path.
83 |
84 | You set this module name, or module path, by adding ``skyhook.constants.Constands.module`` ("_Module") to the parameters of your request.
85 |
86 | .. note::
87 |
88 | This might not be working correctly 100% of the time. For now, it might be best to avoid having the same function name in different modules. You're a creative person, think of a better, COOLER function name than the one that already exists!
--------------------------------------------------------------------------------
/docs/source/skyhook.modules.rst:
--------------------------------------------------------------------------------
1 | skyhook.modules package
2 | =======================
3 |
4 | Submodules
5 | ----------
6 |
7 | skyhook.modules.blender module
8 | ------------------------------
9 |
10 | .. automodule:: skyhook.modules.blender
11 | :members:
12 | :undoc-members:
13 | :show-inheritance:
14 |
15 | skyhook.modules.core module
16 | ---------------------------
17 |
18 | .. automodule:: skyhook.modules.core
19 | :members:
20 | :undoc-members:
21 | :show-inheritance:
22 |
23 | skyhook.modules.houdini module
24 | ------------------------------
25 |
26 | .. automodule:: skyhook.modules.houdini
27 | :members:
28 | :undoc-members:
29 | :show-inheritance:
30 |
31 | skyhook.modules.maya\_mod module
32 | --------------------------------
33 |
34 | .. automodule:: skyhook.modules.maya_mod
35 | :members:
36 | :undoc-members:
37 | :show-inheritance:
38 |
39 | skyhook.modules.unreal module
40 | -----------------------------
41 |
42 | .. automodule:: skyhook.modules.unreal
43 | :members:
44 | :undoc-members:
45 | :show-inheritance:
46 |
47 | Module contents
48 | ---------------
49 |
50 | .. automodule:: skyhook.modules
51 | :members:
52 | :undoc-members:
53 | :show-inheritance:
54 |
--------------------------------------------------------------------------------
/docs/source/skyhook.rst:
--------------------------------------------------------------------------------
1 | skyhook package
2 | ===============
3 |
4 | Subpackages
5 | -----------
6 |
7 | .. toctree::
8 | :maxdepth: 4
9 |
10 | skyhook.modules
11 |
12 | Submodules
13 | ----------
14 |
15 | skyhook.client module
16 | ---------------------
17 |
18 | .. automodule:: skyhook.client
19 | :members:
20 | :undoc-members:
21 | :show-inheritance:
22 |
23 | skyhook.constants module
24 | ------------------------
25 |
26 | .. automodule:: skyhook.constants
27 | :members:
28 | :undoc-members:
29 | :show-inheritance:
30 |
31 | skyhook.logger module
32 | ---------------------
33 |
34 | .. automodule:: skyhook.logger
35 | :members:
36 | :undoc-members:
37 | :show-inheritance:
38 |
39 | skyhook.server module
40 | ---------------------
41 |
42 | .. automodule:: skyhook.server
43 | :members:
44 | :undoc-members:
45 | :show-inheritance:
46 |
47 | Module contents
48 | ---------------
49 |
50 | .. automodule:: skyhook
51 | :members:
52 | :undoc-members:
53 | :show-inheritance:
54 |
--------------------------------------------------------------------------------
/docs/source/unreal_engine_server.rst:
--------------------------------------------------------------------------------
1 | SkyHook server in Unreal
2 | =========================
3 |
4 | .. warning::
5 | Only supporting Unreal Engine 4.26 and up!
6 |
7 |
8 | Unreal is a bit of a different beast. It does support Python for editor related tasks, but seems to be starving any threads pretty quickly. That's why it's pretty much impossible to run the SkyHook server in Unreal like we're able to do so in other programs. However, as explained in the main outline, SkyHook clients don't have to necessarily connect to SkyHook servers. That's why we can use ``skyhook.client.UnrealClient`` with Unreal Engine's built-in Web Remote Control.
9 |
10 | Web Remote Control is a plugin you have first have to load before you can use it. It's still very much in beta right now and changing any functionality is not easy. After you've loaded the plugin, start it by typing ``WebControl.StartServer`` in the default ``Cmd`` console in the Output Log window. Or, if you want to always start with the project, enter ``WebControl.EnableServerOnStartup 1``.
11 |
12 | Loading a SkyHook module in Unreal is done by just importing it like normal. I recommend putting the code for the SkyHook module in a file called "skyhook" in the Python folder of your project (/Game/Content/Python). If it is, you can just do the following to load it:
13 |
14 | .. code-block:: python
15 |
16 | import skyhook
17 |
18 |
19 | If you want to load this automatically when your start your project, put the import statement in a file called ``init_unreal.py`` inside your Python folder.
20 |
21 | .. note::
22 |
23 | When importing a Python module, Unreal (Python) creates a ``__pycache__`` folder in the same location you're loading your module from. In some cases leaving this folder in place can cause a crash when you close your editor. You can add the following couple of lines to your ``init_unreal.py`` file to clean up the pycache folder after importing.
24 |
25 | .. code-block:: python
26 |
27 | import os
28 |
29 | pycache_folder = os.path.join(os.path.dirname(__file__), "__pycache__")
30 |
31 | if os.path.isdir(pycache_folder):
32 | shutil.rmtree(pycache_folder)
33 |
34 |
35 | The SkyHook Unreal module
36 | --------------------------
37 |
38 | Unreal has specific requirements to run Python code. So you have to keep that in mind when adding functionality to the SkyHook module. In order for it to be "seen" in Unreal you need to decorate your class and functions with specific Unreal decorators.
39 |
40 | Decorating the class with ``unreal.uclass()``:
41 |
42 | .. code-block:: python
43 |
44 | import unreal
45 |
46 | @unreal.uclass()
47 | class SkyHookCommands(unreal.BlueprintFunctionLibrary):
48 | pass
49 |
50 |
51 | Epic recommends marking all your functions as ``static``:
52 |
53 | .. code-block:: python
54 |
55 | @unreal.ufunction(static=True)
56 | def say_hello():
57 | unreal.log_warning("Hello!")
58 |
59 |
60 |
61 | If you want your function to accept parameters, you need to add them in the decorator like this
62 |
63 | .. code-block:: python
64 |
65 | import os
66 |
67 | @unreal.ufunction(params=[str, str], static=True)
68 | def rename_asset(asset_path, new_name):
69 | dirname = os.path.dirname(asset_path)
70 | new_name = dirname + "/" + new_name
71 | unreal.EditorAssetLibrary.rename_asset(asset_path, new_name)
72 | unreal.log_warning("Renamed to %s" % new_name)
73 |
74 |
75 | .. note::
76 | You can not use Python's ``list`` in the decorator for the Unreal functions. Use ``unreal.Array(type)``, eg: ``unreal.Array(float)``.
77 |
78 | .. note::
79 | You can not use Python's ``dict`` in the decorator for the Unreal functions. Use ``unreal.Map(key type, value type)``, eg: ``unreal.Map(str, int)``
80 |
81 | Add the return type in your decorator like this:
82 |
83 | .. code-block:: python
84 |
85 | @unreal.ufunction(params=[int], ret=unreal.Map(str, int), static=True)
86 | def get_dictionary(health_multiplier):
87 | return_dict = {
88 | "health": 20 * health_multiplier,
89 | "damage": 90
90 | }
91 |
92 | return return_dict
93 |
94 |
95 | Returning segues us into a couple of things to keep in mind.
96 |
97 |
98 |
99 | Things to keep in mind
100 | ----------------------
101 |
102 | .. warning::
103 | In 4.26 and 4.26.1 there's a bug that causes your editor to crash if you're returning a non-empty ``unreal.Array``` over Web Remote Control from Python. It's been reported and hopefully will get fixed soon.
104 |
105 |
106 | This is an example function that will crash UE.
107 |
108 | .. code-block:: python
109 |
110 | @unreal.ufunction(ret=unreal.Array(float), static=True)
111 | def get_list_float():
112 | return_list = [1.2, 55.9, 65.32]
113 | return return_list
114 |
115 | A way to get around this is to return a string version of the list, instead of the actual list. Since the return object that's being sent back to the client needs to be a JSON, you can't be returning any "exotic" objects in a list anyway.
116 |
117 | This function WON'T crash your editor:
118 |
119 | .. code-block:: python
120 |
121 | @unreal.ufunction(ret=str, static=True)
122 | def get_list_float():
123 | return_list = [1.2, 55.9, 65.32]
124 |
125 | #stringify it!
126 | return str(return_list)
127 |
128 | When the return value is sent back to the client, the client will first try to ``eval`` it to see if it contains a Python object. This happens in ``skyhook.client.UnrealClient.execute```. While this was initially added as a bit of a hack to just get it working, it actually soon became useful in another way.
129 |
130 | Consider the following function. We want to return a dictionary where the values are lists of integers:
131 |
132 | .. code-block:: python
133 |
134 | @unreal.ufunction(ret=unreal.Map(str, int), static=True)
135 | def get_dictionary_with_lists():
136 | return_dict = {
137 | "checkpoints": [4, 18, 25],
138 | "num_enemies": [3, 9, 12]
139 | }
140 |
141 | return return_dict
142 |
143 | Unreal will throw the following error:
144 |
145 | .. warning::
146 |
147 | `TypeError: Map: 'value' (Array (IntProperty)) cannot be a container element type (directly nested containers are not supported - consider using an intermediary struct instead)`
148 |
149 | However, when the ``return_dict`` is first turned into a string before returning it (be sure to also set your return type to string, ``ret=str``) everything works just fine. Your client on the receiving end will turn it into a Python `dict` again.
150 |
--------------------------------------------------------------------------------
/examples/blender_client.py:
--------------------------------------------------------------------------------
1 | from skyhook.client import BlenderClient
2 | from skyhook.modules import blender
3 | from skyhook.modules import core
4 | from skyhook import ServerCommands
5 |
6 | # Make the client
7 | client = BlenderClient()
8 |
9 | # Let's make a cube using the blender module
10 | client.execute(blender.make_cube)
11 |
12 | # Let's print something using the default core module
13 | client.execute(core.echo_message, parameters={"message": "Hi there, Blender!"})
14 |
15 | # Let's shut the server down
16 | client.execute(ServerCommands.SKY_SHUTDOWN)
--------------------------------------------------------------------------------
/examples/server_in_blender.py:
--------------------------------------------------------------------------------
1 | # Clear the default system console
2 | print("\n" * 60)
3 |
4 | import bpy
5 |
6 | import sys
7 | import subprocess
8 | import pkg_resources
9 |
10 | import traceback
11 |
12 | # Blender needs these packages to run skyhook
13 | required = {"PySide2", "requests", "git+https://github.com/EmbarkStudios/skyhook"}
14 |
15 | # Let's fine the missing packages
16 | installed = {pkg.key for pkg in pkg_resources.working_set}
17 | missing = required - installed
18 |
19 | # and if there are any, pip install them
20 | if missing:
21 | python = sys.executable
22 | subprocess.check_call([python, "-m", "pip", "install", *missing], stdout=subprocess.DEVNULL)
23 |
24 | try:
25 | from PySide2.QtCore import *
26 | from PySide2.QtWidgets import *
27 | from PySide2.QtGui import *
28 | except Exception:
29 | print("Couldn't install PySide2")
30 |
31 | class SkyhookServer():
32 | def __init__(self):
33 | self.thread, self.executor, self.server = \
34 | server.start_executor_server_in_thread(host_program=HostPrograms.blender,
35 | load_modules=[blender])
36 | self.__app = QApplication.instance()
37 | if not self.__app:
38 | self.__app = QApplication(["blender"])
39 | if self.__app:
40 | self.__app.processEvents()
41 |
42 | if __name__ == "__main__":
43 | try:
44 | import skyhook
45 | from skyhook import HostPrograms
46 | from skyhook.modules import blender
47 | from skyhook import server
48 |
49 | bpy.types.Scene.skyhook_server = SkyhookServer()
50 |
51 | except Exception as err:
52 | print(str(traceback.format_exc()))
53 | print(str(err))
54 |
--------------------------------------------------------------------------------
/icon/sky_hook_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/icon/sky_hook_logo.png
--------------------------------------------------------------------------------
/icon/sky_hook_logo.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/icon/sky_hook_logo.psd
--------------------------------------------------------------------------------
/package.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | name = 'skyhook'
4 |
5 | version = '0.1.0'
6 |
7 | requires = [
8 | "platform-windows",
9 | "arch-AMD64",
10 | ]
11 |
12 |
13 | build_command = "python {root}/package.py {install}"
14 |
15 | def commands():
16 | import os
17 | # Path to cmake.exe and cmake-gui.exe
18 | env.PYTHONPATH.append(os.path.join("{root}", "python"))
19 |
20 |
21 | def build(*args):
22 | import os
23 | import shutil
24 |
25 | # build config
26 | is_build = os.getenv("REZ_BUILD_ENV")
27 | build_path = os.getenv("REZ_BUILD_PATH")
28 | is_install = os.getenv("REZ_BUILD_INSTALL")
29 | install_path = os.getenv("REZ_BUILD_INSTALL_PATH")
30 | source_path = os.getenv("REZ_BUILD_SOURCE_PATH")
31 | is_install = os.getenv("REZ_BUILD_INSTALL")
32 |
33 | if is_install:
34 | import glob
35 | src = os.path.join(source_path, "skyhook")
36 | dest = os.path.join(install_path, "python", "skyhook")
37 | shutil.rmtree(dest, ignore_errors=True)
38 | shutil.copytree(src, dest, dirs_exist_ok=True)
39 |
40 |
41 | if __name__ == "__main__":
42 | import sys
43 | build(*sys.argv)
44 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import setuptools
2 |
3 | with open("README.md", "r") as fh:
4 | long_description = fh.read()
5 |
6 | # any data files that match this pattern will be included
7 | data_files_to_include = ["*.png"]
8 |
9 | setuptools.setup(
10 | name="skyhook",
11 | version="3.0.0",
12 | author="Niels Vaes",
13 | author_email="nielsvaes@gmail.com",
14 | description="Engine and DCC communication system",
15 | long_description=long_description,
16 | long_description_content_type="text/markdown",
17 | url="https://github.com/EmbarkStudios/skyhook",
18 | install_requires=['requests'],
19 | packages=setuptools.find_packages(),
20 | package_data={
21 | "": data_files_to_include,
22 | },
23 | python_requires='>=3.6',
24 | classifiers=[
25 | "Operating System :: OS Independent",
26 | "Programming Language :: Python :: 3",
27 | "Programming Language :: Python :: 3.6",
28 | "Programming Language :: Python :: 3.7",
29 | "Programming Language :: Python :: 3.8",
30 | "Programming Language :: Python :: 3.9",
31 | "Programming Language :: Python :: 3.10",
32 | "Programming Language :: Python :: 3.11",
33 | "Programming Language :: Python :: 3.12",
34 | "Programming Language :: Python :: 3.13",
35 | "License :: OSI Approved :: MIT License",
36 | "Development Status :: 5 - Production/Stable",
37 | ],
38 | license="MIT",
39 | keywords='engine dcc communication system',
40 | project_urls={
41 | 'Homepage': 'https://github.com/EmbarkStudios/skyhook',
42 | 'Documentation': 'https://github.com/EmbarkStudios/skyhook/wiki',
43 | 'Issue Tracker': 'https://github.com/EmbarkStudios/skyhook/issues',
44 | },
45 | )
--------------------------------------------------------------------------------
/skyhook/__init__.py:
--------------------------------------------------------------------------------
1 | from .logger import Logger
2 | logger = Logger()
3 |
4 | from . import modules
5 | from . import constants
6 | from . import client
7 | try:
8 | from . import server
9 | except:
10 | logger.warning("Server import failed, so it won't be available in this instance")
11 |
12 | from .constants import *
--------------------------------------------------------------------------------
/skyhook/client.py:
--------------------------------------------------------------------------------
1 | import pprint
2 | from datetime import datetime
3 |
4 | from .constants import HostPrograms, Results, Ports, ServerCommands, Errors
5 | import requests
6 |
7 | try:
8 | import websockets
9 | import asyncio
10 | except:
11 | pass
12 | # print("failed to import websockets/asyncio")
13 |
14 | class Client(object):
15 | """
16 | Base client from which all other Clients will inherit
17 | """
18 | def __init__(self, port=Ports.undefined, host_address="127.0.0.1"):
19 | super(Client, self).__init__()
20 |
21 | self.host_address = host_address
22 | self.port = port
23 |
24 | self.__timeout = 1000
25 | self.__echo_execution = True
26 | self.__echo_payload = True
27 | self._is_executing = False
28 |
29 | def set_timeout(self, value):
30 | """
31 | If set to anything bigger than 0, will time out the connection to the server after this value in seconds
32 |
33 | :param value: *float*
34 | :return: None
35 | """
36 | self.__timeout = value
37 |
38 | def timeout(self):
39 | """
40 | Return the current timeout value
41 |
42 | :return: *float*
43 | """
44 | return self.__timeout
45 |
46 | def set_echo_execution(self, value):
47 | """
48 | If set to true, the client will print out the response coming from the server
49 |
50 | :param value: *bool*
51 | :return: None
52 | """
53 | self.__echo_execution = value
54 |
55 | def echo_execution(self):
56 | """
57 | Return True or False whether or not responses from the server are printed in the client
58 |
59 | :return: *bool*
60 | """
61 | return self.__echo_execution
62 |
63 | def set_echo_payload(self, value):
64 | """
65 | If set to true, the client will print the JSON payload it's sending to the server
66 |
67 | :param value: *bool*
68 | :return:
69 | """
70 | self.__echo_payload = value
71 |
72 | def echo_payload(self):
73 | """
74 | Return True or False whether or not the JSON payloads sent to the server are printed in the client
75 |
76 | :return:
77 | """
78 | return self.__echo_payload
79 |
80 | def is_executing(self):
81 | """
82 | Retrun True or False if the client is still executing a command
83 |
84 | :return: *bool*
85 | """
86 | return self._is_executing
87 |
88 | def is_host_online(self):
89 | """
90 | Convenience function to call on the client. "is_online" comes from the core module
91 |
92 | :return: *bool*
93 | """
94 | try:
95 | response = self.execute("is_online", {})
96 | return response.get(Results.return_value) is True
97 | except Errors.SkyHookCantReachServer:
98 | return False
99 |
100 |
101 | def execute(self, command, parameters={}, timeout=0):
102 | """
103 | Executes a given command for this client. The server will look for this command in the modules it has loaded
104 |
105 | :param command: *string* or *function* The command name or the actual function object that you can import from the modules module
106 | :param parameters: *dict* of the parameters (arguments) for the the command. These have to match the argument names on the function in the module exactly
107 | :param timeout: *float* time in seconds after which the request will timeout. If not set here, self.time_out will be used (1.5 by default)
108 | :return: *dict* of the response coming from the server
109 |
110 | From a SkyHook server it looks like:
111 | {
112 | 'ReturnValue': ['Camera', 'Cube', 'Cube.001', 'Light'],
113 | 'Success': True,
114 | 'Time': '09:43:18'
115 | }
116 |
117 |
118 | From Unreal it looks like this:
119 | {
120 | 'ReturnValue': ['/Game/Developers/cooldeveloper/Maps/ScatterDemoLevel/ScatterDemoLevelMaster.ScatterDemoLevelMaster',
121 | '/Game/Apple/Core/UI/Widgets/WBP_CriticalHealthLevelVignette.WBP_CriticalHealthLevelVignette',
122 | '/Game/Apple/Lighting/LUTs/RGBTable16x1_Level_01.RGBTable16x1_Level_01']
123 | }
124 | """
125 | self._is_executing = True
126 | if timeout <= 0:
127 | timeout = self.__timeout
128 |
129 | if callable(command):
130 | command = command.__name__
131 |
132 | url = "http://%s:%s" % (self.host_address, self.port)
133 | payload = self.__create_payload(command, parameters)
134 |
135 | try:
136 | response = requests.post(url, json=payload, timeout=timeout).json()
137 | except requests.exceptions.ConnectionError as err:
138 | raise Errors.SkyHookCantReachServer(f"Can't reach server {self.host_address} on port {self.port}")
139 |
140 | if self.echo_payload():
141 | pprint.pprint(payload)
142 |
143 | if self.echo_execution():
144 | pprint.pprint(response)
145 |
146 | self._is_executing = False
147 |
148 | return response
149 |
150 | def __create_payload(self, command, parameters):
151 | """
152 | Constructs the dictionary for the JSON payload that will be sent to the server
153 |
154 | :param command: *string* name of the command
155 | :param parameters: *dictionary*
156 | :return: *dictionary*
157 | """
158 | payload = {
159 | "FunctionName": command,
160 | "Parameters": parameters
161 | }
162 |
163 | return payload
164 |
165 | class SubstancePainterClient(Client):
166 | """
167 | Custom client for Substance Painter
168 | """
169 | def __init__(self, port=Ports.substance_painter, host_address="127.0.0.1"):
170 | super().__init__(port=port, host_address=host_address)
171 | self.host_program = HostPrograms.substance_painter
172 |
173 |
174 | class BlenderClient(Client):
175 | """
176 | Custom client for Blender
177 | """
178 | def __init__(self, port=Ports.blender, host_address="127.0.0.1"):
179 | super().__init__(port=port, host_address=host_address)
180 | self.host_program = HostPrograms.blender
181 |
182 |
183 | class MayaClient(Client):
184 | """
185 | Custom client for Maya
186 | """
187 | def __init__(self, port=Ports.maya, host_address="127.0.0.1"):
188 | super().__init__(port=port, host_address=host_address)
189 | self.host_program = HostPrograms.maya
190 |
191 |
192 | class HoudiniClient(Client):
193 | """
194 | Custom client for Houdini
195 | """
196 | def __init__(self, port=Ports.houdini, host_address="127.0.0.1"):
197 | super().__init__(port=port, host_address=host_address)
198 | self.host_program = HostPrograms.houdini
199 |
200 |
201 | class UnrealClient(Client):
202 | """
203 | Custom client for Unreal. This overwrites most of the basic functionality because we can't run a Skyhook server
204 | in Unreal, but have to rely on Web Remote Control.
205 |
206 | There is a file called skyhook in /Game/Python/ that holds the SkyHook classes to be used with this client.
207 | This file has to be loaded by running "import skyhook" in Unreal's Python editor, or imported when the project
208 | is loaded.
209 |
210 | """
211 |
212 | def __init__(self, port=Ports.unreal, host_address="127.0.0.1"):
213 | super().__init__(port=port, host_address=host_address)
214 | self.host_program = HostPrograms.unreal
215 | self.__command_object_path = "/Engine/PythonTypes.Default__SkyHookCommands"
216 | self.__server_command_object_path = "/Engine/PythonTypes.Default__SkyHookServerCommands"
217 | self.__headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
218 |
219 | def execute(self, command, parameters={}, timeout=0, function=True, property=False):
220 | """
221 | Will execute the command so Web Remote Control understands it.
222 |
223 | :param command: *string* command name
224 | :param parameters: *dict* of the parameters (arguments) for the the command. These have to match the argument names on the function in the module exactly
225 | :param timeout: *float* time in seconds after which the request will timeout. If not set here, self.time_out will be used (1.5 by default)
226 | :param function: *bool* ignore, not used
227 | :param property: *bool* ignore, not used
228 | :return: *dict* of the response coming from Web Remote Control
229 | """
230 | self._is_executing = True
231 | if timeout <= 0:
232 | timeout = self.timeout()
233 |
234 | url = "http://%s:%s/remote/object/call" % (self.host_address, self.port)
235 |
236 | if command in dir(ServerCommands):
237 | payload = self.__create_payload(command, parameters, self.__server_command_object_path)
238 | used_object_path = self.__server_command_object_path
239 | else:
240 | payload = self.__create_payload(command, parameters, self.__command_object_path)
241 | used_object_path = self.__command_object_path
242 |
243 | try:
244 | response = requests.put(url, json=payload, headers=self.__headers, timeout=timeout).json()
245 | except requests.exceptions.ConnectionError:
246 | raise(Errors.SkyHookCantReachServer("Can't connect to Unreal, is Unreal running and the Remote Control API plugin loaded?"))
247 |
248 | try:
249 | # UE 4.26: Returning an unreal.Array() that's not empty crashes the editor
250 | # (https://udn.unrealengine.com/s/question/0D52L00005KOA24SAH/426-crashes-when-trying-to-return-an-unrealarray-over-web-remote-control-from-python)
251 | # The (ugly) workaround for this is to return the list as a string eg "['/Game/Content/file', '/Game/Content/file2']"
252 | # Then we try to eval the string here, if it succeeds, we'll have the list.
253 |
254 | # However!
255 | # This actually serves another purpose. It's much easier to return a dictionary from Unreal as a string compared
256 | # to an unreal.Map. When returning the stringified dict, you don't need to explicitly tell Unreal what type
257 | # the keys and values are. It also gets around errors like the following:
258 | #
259 | # -------------------------------------------------------------------------------------------------------
260 | # `TypeError: Map: 'value' (Array (IntProperty)) cannot be a container element type (directly nested
261 | # containers are not supported - consider using an intermediary struct instead)`
262 | # -------------------------------------------------------------------------------------------------------
263 | #
264 | # You'd get this error in Uneal when you try to declare the return type of a function as `unreal.Map(str, unreal.Array(int)`
265 | # When you'd want the value of the keys to be a list of ints, for example.
266 |
267 | # On top of that, stringifying a dict also just lets you return a bunch of values that are not all the same type.
268 | # As long as it can get serialized into a JSON object, it will come out this end as a proper dictionary.
269 | evalled_return_value = eval(response.get(Results.return_value))
270 | response = {Results.return_value: evalled_return_value}
271 | except:
272 | pass
273 |
274 | if self.echo_payload():
275 | pprint.pprint(payload)
276 |
277 | if self.echo_execution():
278 | pprint.pprint(response)
279 |
280 | self._is_executing = False
281 |
282 | return response
283 |
284 | def set_command_object_path(self, path="/Engine/PythonTypes.Default__PythonClassName"):
285 | """
286 | Set the object path for commands
287 |
288 | :param path: *string* path. For Python functions, this has to be something like /Engine/PythonTypes.Default__
289 | You need to add the leading 'Default__', this is what Unreal Engine expects
290 | :return: None
291 | """
292 | self.__command_object_path = path
293 |
294 | def command_object_path(self):
295 | """
296 | Gets the command object path
297 |
298 | :return: *string* Object path
299 | """
300 | return self.__command_object_path
301 |
302 | def __create_payload(self, command, parameters, object_path, echo_payload=True):
303 | payload = {
304 | "ObjectPath": object_path,
305 | "FunctionName": command,
306 | "Parameters": parameters,
307 | "GenerateTransaction": True
308 | }
309 |
310 | return payload
311 |
312 | # class WebsocketClient(Client):
313 | # def __init__(self):
314 | # super(WebsocketClient, self).__init__()
315 | # self.uri = "ws://%s:%s" % (self.host_address, self.port)
316 | # print(self.uri)
317 | #
318 | # def execute(self, command, parameters={}):
319 | # payload = self.__create_payload(command, parameters)
320 | # print(payload)
321 | #
322 | # async def send():
323 | # async with websockets.connect(self.uri) as websocket:
324 | # await websocket.send(payload)
325 | # response = await websocket.recv()
326 | # return response
327 | #
328 | # response = asyncio.get_event_loop().run_until_complete(send())
329 | # return response
330 | #
331 | # def __create_payload(self, command, parameters):
332 | # """
333 | # Constructs the dictionary for the JSON payload that will be sent to the server
334 | #
335 | # :param command: *string* name of the command
336 | # :param parameters: *dictionary*
337 | # :return: *dictionary*
338 | # """
339 | # payload = {
340 | # "FunctionName": command,
341 | # "Parameters": parameters
342 | # }
343 | #
344 | # return payload
345 |
--------------------------------------------------------------------------------
/skyhook/constants.py:
--------------------------------------------------------------------------------
1 | class Constants:
2 | """
3 | Some very general constants that are used throughout SkyHook
4 | """
5 | function_name = "FunctionName"
6 | parameters = "Parameters"
7 | module = "_Module"
8 | undefined = "undefined"
9 |
10 | is_skyhook_module = "is_skyhook_module"
11 |
12 |
13 | class Results:
14 | time = "Time"
15 | success = "Success"
16 | return_value = "ReturnValue"
17 | command = "Command"
18 |
19 |
20 | class Ports:
21 | """
22 | Ports that the host programs use and for the clients to connect to
23 | """
24 | undefined = 65500
25 | maya = 65501
26 | houdini = 65502
27 | blender = 65504
28 | substance_painter = 65505
29 | unreal = 30010
30 |
31 |
32 | class HostPrograms:
33 | """
34 | List of host programs
35 | """
36 | blender = "blender"
37 | unreal = "unreal"
38 | maya = "maya"
39 | houdini = "houdini"
40 | substance_painter = "substance_painter"
41 |
42 |
43 | class ServerCommands:
44 | """
45 | These are commands that are not run from any modules, but in the server class. In case of Unreal, since we don't
46 | control the server, these commands will have to be overridden in the client.
47 | """
48 | SKY_SHUTDOWN = "SKY_SHUTDOWN"
49 | SKY_LS = "SKY_LS"
50 | SKY_RELOAD_MODULES = "SKY_RELOAD_MODULES"
51 | SKY_HOTLOAD = "SKY_HOTLOAD"
52 | SKY_UNLOAD = "SKY_UNLOAD"
53 | SKY_FUNCTION_HELP = "SKY_FUNCTION_HELP"
54 |
55 |
56 | class Errors:
57 | """
58 | Errors when things go wrong
59 | """
60 | class SkyHookCantReachServer(Exception):
61 | pass
62 |
63 | CALLING_FUNCTION = "ERROR 0x1: An error occurred when calling the function"
64 | IN_FUNCTION = "ERROR 0x2: An error occurred when executing the function"
65 | SERVER_COMMAND = "ERROR 0x3: An error occurred processing this server command"
66 | SERVER_RELOAD = "ERROR 0x4: An error occurred with reloading a module on the server"
67 | TIMEOUT = "ERROR 0x5: The command timed out"
68 | NO_RESPONSE = "ERROR 0x7: No response was generated, is the function on the server returning anything?"
69 |
70 |
71 | class UnrealTypes:
72 | """
73 | Unreal object types as strings
74 | """
75 | STATICMESH = "StaticMesh"
76 | SKELETALMESH = "SkeletalMesh"
77 | TEXTURE2D = "Texture2D"
78 | MATERIAL = "Material"
79 | MATERIALINSTANCE = "MaterialInstance"
80 |
81 |
82 | class ServerEvents:
83 | """
84 |
85 | """
86 | is_terminated = "is_terminated"
87 | exec_command = "exec_command"
88 | command = "command"
89 |
--------------------------------------------------------------------------------
/skyhook/logger.py:
--------------------------------------------------------------------------------
1 | import os
2 | import datetime
3 |
4 | class Status:
5 | SUCCESS = "Success"
6 | WARNING = "Warning"
7 | INFO = "Info"
8 | ERROR = "Error"
9 | DEBUG = "Debug"
10 |
11 |
12 | class Logger(object):
13 | def __init__(self, file_path=None, print_to_screen=True):
14 | super(Logger, self).__init__()
15 |
16 | self.file_path = file_path if file_path is not None else os.path.join(os.path.expanduser("~"), "skyhook.log")
17 | self.__print_to_screen = print_to_screen
18 |
19 | self.debug("\n\n====================== NEW LOG ======================", print_to_screen=False)
20 |
21 | def success(self, message, print_to_screen=True):
22 | self.__add_to_log(Status.SUCCESS, message, print_to_screen)
23 |
24 | def warning(self, message, print_to_screen=True):
25 | self.__add_to_log(Status.WARNING, message, print_to_screen)
26 |
27 | def info(self, message, print_to_screen=True):
28 | self.__add_to_log(Status.INFO, message, print_to_screen)
29 |
30 | def error(self, message, print_to_screen=True):
31 | self.__add_to_log(Status.ERROR, message, print_to_screen)
32 |
33 | def debug(self, message, print_to_screen=True):
34 | self.__add_to_log(Status.DEBUG, message, print_to_screen)
35 |
36 | def __add_to_log(self, status, message, print_to_screen):
37 | time = datetime.datetime.now().strftime("%Y/%m/%d-%H:%M:%S")
38 | line = "SKYHOOK [%s] | %s | %s" % (time, status, message)
39 |
40 | with open(self.file_path, "a") as out_file:
41 | out_file.write(line + "\n")
42 |
43 | if print_to_screen:
44 | print(line)
45 |
--------------------------------------------------------------------------------
/skyhook/modules/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/skyhook/modules/__init__.py
--------------------------------------------------------------------------------
/skyhook/modules/blender.py:
--------------------------------------------------------------------------------
1 | """
2 | Just some examples to help you get on your way with adding in functionality.
3 |
4 | """
5 |
6 |
7 | try:
8 | import bpy
9 | except:
10 | pass
11 |
12 | def make_cube():
13 | try:
14 | with bpy.context.temp_override(**__get_window_ctx()):
15 | bpy.ops.mesh.primitive_cube_add(location=(0, 0, 0))
16 | except Exception as e:
17 | return f"Cube failed: {str(e)}"
18 |
19 |
20 | def make_cube_at_location(location=(0, 0, 0)):
21 | with bpy.context.temp_override(**__get_window_ctx()):
22 | bpy.ops.mesh.primitive_cube_add(location=location)
23 |
24 |
25 | def export_to_usd(filepath="C:/tmp/test.usd", selected_only= True):
26 | """
27 | Example to export to USD.
28 | """
29 | try:
30 | bpy.ops.wm.usd_export(
31 | filepath=filepath,
32 | check_existing=False,
33 | selected_objects_only=selected_only,
34 | visible_objects_only=True,
35 | export_materials=False,
36 | generate_preview_surface=False,
37 | export_textures=False,
38 | use_instancing=True,
39 | relative_paths=True,
40 | export_normals=True,
41 | )
42 | return "Export completed successfully"
43 | except Exception as e:
44 | return f"Export failed: {str(e)}"
45 |
46 |
47 | def get_all_objects_in_scene():
48 | """
49 | Gets all the objects in the scene
50 |
51 | :return: *list* with object names
52 | """
53 | return [obj.name for obj in bpy.data.objects]
54 |
55 | def say_hello():
56 | """
57 | Says hello!
58 |
59 | :return: *string*
60 | """
61 | print("Hello from Blender")
62 | return "I said hello in Blender"
63 |
64 |
65 | def __get_window_ctx():
66 | """
67 | Gets a more complete Window Context for blender operations.
68 | """
69 | window = bpy.context.window_manager.windows[0]
70 | screen = window.screen
71 |
72 | # Find a 3D view area
73 | areas_3d = [area for area in screen.areas if area.type == 'VIEW_3D']
74 | if areas_3d:
75 | area = areas_3d[0]
76 | region = [region for region in area.regions if region.type == 'WINDOW'][0]
77 | else:
78 | # Fallback to any area
79 | area = screen.areas[0]
80 | region = area.regions[0]
81 |
82 | ctx = {
83 | 'window': window,
84 | 'screen': screen,
85 | 'area': area,
86 | 'region': region,
87 | 'scene': bpy.context.scene,
88 | 'view_layer': bpy.context.view_layer,
89 | }
90 | return ctx
--------------------------------------------------------------------------------
/skyhook/modules/core.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | from .. import client
3 |
4 |
5 | def is_online():
6 | """
7 | Being able to call this function means the server is online and able to handle requests.
8 |
9 | :return: True
10 | """
11 | return True
12 |
13 | def echo_message(message):
14 | print(message)
15 | return "I printed: %s" % message
16 |
17 |
18 | def run_python_script(script_path):
19 | import runpy
20 | runpy.run_path(script_path, init_globals=globals(), run_name="__main__")
21 |
--------------------------------------------------------------------------------
/skyhook/modules/houdini.py:
--------------------------------------------------------------------------------
1 | try:
2 | import hou
3 | except:
4 | pass
5 |
6 | def say_hi_from_houdini():
7 | """
8 | This says Hi in Houdini
9 |
10 | :return: *string*
11 | """
12 | print("Hi from Houdini!")
13 | return("I said hi")
14 |
15 | def create_node(path, type, name="NisseNode"):
16 | obj = hou.node(path)
17 | obj.createNode(type, name)
18 |
--------------------------------------------------------------------------------
/skyhook/modules/maya_mod.py:
--------------------------------------------------------------------------------
1 | import traceback
2 | try:
3 | import pymel.core as pm
4 | import maya.cmds as cmds
5 | import maya.utils as utils
6 | except:
7 | print(str(traceback.format_exc()))
8 | pass
9 |
10 |
11 | def make_cube():
12 | """
13 | Example function that will make a cube named Nisse
14 |
15 | :return:
16 | """
17 | pm.polyCube(name="Nisse")
18 | return None
19 |
20 | def make_sphere(name="Nisse", create_uvs=1, radius=3):
21 | """
22 | Example function that will make a sphere name Nisse
23 |
24 | :return:
25 | """
26 | pm.polySphere(name=name, createUVs=create_uvs, radius=radius)
27 | return "I made a sphere"
28 |
29 | def raw_maya(command, args=[], kwargs={}):
30 | """
31 | Handle a "raw" maya.cmds or pymel command. Expects args to be a list and kwargs to be a dictionary.
32 |
33 | Eg::
34 | import skyhook.client
35 | maya_client = skyhook.client.MayaClient()
36 | maya_client.execute("raw_maya", {"command": "pm.polySphere", "kwargs": {"radius": 20}})
37 |
38 |
39 | :param command: *string* of complete command. eg: pm.ls or cmds.polyCube
40 | :param args: *list* of unnamed arguments
41 | :param kwargs: *dict* with keywords that are needed for your command
42 | :return: *string* of whatever the command returned in Maya
43 | """
44 | kwargs = dict((str(k), v) for k, v in kwargs.items())
45 | func = eval(command)
46 | print(kwargs)
47 |
48 |
49 | result = func(*args, **kwargs)
50 | return str(result)
51 |
52 |
53 | def execute_python(python_script):
54 | """
55 | Executes a complete Python script
56 |
57 | :param python_script: *string* Python code
58 | :return: None
59 | """
60 | exec(python_script)
61 |
62 |
63 | def warning(message):
64 | pm.warning(message)
65 |
66 |
67 | def new_scene():
68 | pm.newFile(force=True)
69 |
--------------------------------------------------------------------------------
/skyhook/modules/substance_painter.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/skyhook/modules/substance_painter.py
--------------------------------------------------------------------------------
/skyhook/modules/unreal.py:
--------------------------------------------------------------------------------
1 | ###########################################################
2 | #
3 | # It's impossible to have the skyhook functionality in this file at this time, due to the way
4 | # Unreal loads its Python scripts. So until there's a better way of doing this, please edit the
5 | # SkyhookCommands class in the skyhook module in the project's Python folder
6 | #
7 | ###########################################################
8 |
9 | pass
10 |
--------------------------------------------------------------------------------
/skyhook/resources/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 | logo_transparent = os.path.join(os.path.dirname(__file__), "sky_hook_logo_transparent.png")
3 | label_transparent = os.path.join(os.path.dirname(__file__), "sky_hook_label_transparent.png")
--------------------------------------------------------------------------------
/skyhook/resources/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/skyhook/resources/favicon.ico
--------------------------------------------------------------------------------
/skyhook/resources/sky_hook_label_transparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/skyhook/resources/sky_hook_label_transparent.png
--------------------------------------------------------------------------------
/skyhook/resources/sky_hook_logo_transparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/skyhook/resources/sky_hook_logo_transparent.png
--------------------------------------------------------------------------------
/skyhook/server.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import threading
3 | import time
4 | import socket
5 | import importlib
6 | import inspect
7 | import types
8 | import json
9 | import traceback
10 | import urllib.parse
11 |
12 | from datetime import datetime
13 | from importlib import reload
14 | from http.server import BaseHTTPRequestHandler, HTTPServer
15 | from typing import Any, Dict, List, Optional, TypeVar, Union, Callable, Type
16 |
17 | from .constants import Constants, Results, ServerCommands, Errors, Ports, HostPrograms, ServerEvents
18 | from .modules import core
19 | from .logger import Logger
20 | logger = Logger()
21 |
22 | T = TypeVar('T')
23 |
24 | class EventEmitter:
25 | """
26 | A simple event emitter similar to Qt's signal/slot mechanism.
27 | """
28 | def __init__(self) -> None:
29 | self._callbacks: Dict[str, List[Callable[..., Any]]] = {}
30 |
31 | def connect(self, event_name: str, callback: Callable[..., Any]) -> None:
32 | """
33 | Connect a callback to an event
34 | """
35 | if event_name not in self._callbacks:
36 | self._callbacks[event_name] = []
37 | self._callbacks[event_name].append(callback)
38 |
39 | def emit(self, event_name: str, *args: Any, **kwargs: Any) -> None:
40 | """
41 | Emit an event with arguments
42 | """
43 | if event_name in self._callbacks:
44 | for callback in self._callbacks[event_name]:
45 | callback(*args, **kwargs)
46 |
47 |
48 | class GenericMainThreadExecutor:
49 | """
50 | You can spawn an object of this class in the main thread of a program, while the server runs in a different thread. Any code
51 | execution will happen in the main thread and the server can run on a separate, non-blocking thread. Useful when you're
52 | running a Skyhook server inside another program.
53 | """
54 | def __init__(self, server: 'Server') -> None:
55 | self.server: 'Server' = server
56 | logger.info(f"Executor started: {type(self)}")
57 |
58 | def execute(self, function_name: str, parameters_dict: Dict[str, Any]) -> None:
59 | """
60 | Executes a function. The response from the SkyHook server will not be returned here, but set in the server's
61 | executor_reply property. When using a MainThreadExecutor, the server will block until its executor_reply
62 | property is set to anything but None.
63 |
64 | :param function_name: *string* name of the function, it will be searched for in the server
65 | :param parameters_dict: *dict* with parameters
66 | :return: None
67 | """
68 | parameters_dict.pop(Constants.module, None)
69 | function: Callable[..., Any] = self.server.get_function_by_name(function_name)
70 | try:
71 | return_value: Any = function(**parameters_dict)
72 | success: bool = True
73 | logger.success(f"MainThreadExecutor executed {function}")
74 | except Exception as err:
75 | trace: str = str(traceback.format_exc())
76 | return_value = trace
77 | success = False
78 | logger.error(f"MainThreadExecutor couldn't execute {function}")
79 | logger.error(str(err))
80 | logger.error(trace)
81 |
82 | result_json: Dict[str, Any] = make_result_json(success, return_value, function_name)
83 | self.server.executor_reply = result_json
84 |
85 |
86 | class MayaExecutor(GenericMainThreadExecutor):
87 | """
88 | A specific MainThreadExecutor for Maya. It will run any code using maya.utils.executeInMainThread.
89 | """
90 | def __init__(self, server: 'Server') -> None:
91 | super().__init__(server)
92 |
93 | try:
94 | import maya.utils
95 | self.executeInMainThreadWithResult: Callable[..., Any] = maya.utils.executeInMainThreadWithResult
96 | logger.success("Fetched maya.utils.executeInMainThreadWithResult")
97 | except:
98 | logger.error("Couldn't fetch maya.utils.executeInMainThreadWithResult")
99 |
100 | def execute(self, function_name: str, parameters_dict: Dict[str, Any]) -> None:
101 | parameters_dict.pop(Constants.module, None)
102 | function: Callable[..., Any] = self.server.get_function_by_name(function_name)
103 |
104 | try:
105 | return_value: Any = self.executeInMainThreadWithResult(function, **parameters_dict)
106 | success: bool = True
107 | logger.success(f"MayaExecutor executed {function}")
108 | except Exception as err:
109 | trace: str = str(traceback.format_exc())
110 | return_value = trace
111 | success = False
112 | logger.error(f"MayaExecutor couldn't execute {function}")
113 | logger.error(str(err))
114 | logger.error(trace)
115 |
116 | result_json: Dict[str, Any] = make_result_json(success, return_value, function_name)
117 | self.server.executor_reply = result_json
118 |
119 |
120 | class BlenderExecutor(GenericMainThreadExecutor):
121 | """
122 | A specific MainThreadExecutor for Blender. It will schedule any code to be registered with
123 | bpy.app.timers, where it will run in the main UI thread of Blender. It uses an inner function
124 | that will wrap the actual code in order to get a proper result back and make sure we can wait
125 | until the code execution is done before we continue.
126 | """
127 | def __init__(self, server: 'Server') -> None:
128 | super().__init__(server)
129 |
130 | try:
131 | import bpy.app.timers
132 | self.register: Callable[[Callable[[], Optional[float]]], None] = bpy.app.timers.register
133 | logger.success("Fetched bpy.app.timers.register")
134 | except:
135 | logger.error("Couldn't fetch bpy.app.timers.register")
136 |
137 | def execute(self, function_name: str, parameters_dict: Dict[str, Any]) -> None:
138 | parameters_dict.pop(Constants.module, None)
139 | function: Callable[..., Any] = self.server.get_function_by_name(function_name)
140 |
141 | done_event: threading.Event = threading.Event()
142 | result: List[Optional[Any]] = [None]
143 | error: List[Optional[str]] = [None]
144 |
145 | def timer_function() -> None:
146 | try:
147 | logger.info(function)
148 | result[0] = function(**parameters_dict)
149 | except Exception as e:
150 | error[0] = str(e)
151 | finally:
152 | logger.info("we're done!")
153 | done_event.set()
154 |
155 | # Unregister timer
156 | return None
157 |
158 | # run the function in Blenders's main thread
159 | self.register(timer_function)
160 | done_event.wait() # Wait until done_event is set(), then continue
161 |
162 | if error[0]:
163 | success: bool = False
164 | logger.error(f"BlenderExecutor couldn't execute {function}")
165 | result_json: Dict[str, Any] = make_result_json(success, error[0], function_name)
166 | else:
167 | success = True
168 | logger.success(f"BlenderExecutor executed {function}")
169 | result_json = make_result_json(success, result[0], function_name)
170 | self.server.executor_reply = result_json
171 |
172 |
173 | class Server:
174 | """
175 | Main SkyHook server class
176 |
177 | There are 3 callbacks that you can hook into from the outside:
178 | ServerEvents.is_terminated: called when stop_listening() is called
179 | ServerEvents.exec_command: called when a non-server command is executed through the MainThreadExecutor
180 | ServerEvents.command: always called with the command name and parameter dictionary,
181 | whether the command was a non-server or server command
182 | """
183 | def __init__(
184 | self,
185 | host_program: Optional[str] = None,
186 | port: Optional[int] = None,
187 | load_modules: List[str] = [],
188 | use_main_thread_executor: bool = False,
189 | echo_response: bool = True
190 | ) -> None:
191 |
192 | self.events: EventEmitter = EventEmitter()
193 | self.executor_reply: Optional[Dict[str, Any]] = None
194 |
195 | if port:
196 | self.port: int = port
197 | else:
198 | self.port = self.__get_host_program_port(host_program)
199 |
200 | self.__host_program: Optional[str] = host_program
201 | self.__keep_running: bool = True
202 | self.__use_main_thread_executor: bool = use_main_thread_executor
203 | self.__http_server: Optional[HTTPServer] = None
204 | self.__echo_response: bool = echo_response
205 | self.__loaded_modules: List[types.ModuleType] = []
206 | self.__loaded_modules.append(core)
207 |
208 | for module_name in load_modules:
209 | self.hotload_module(module_name)
210 |
211 | def start_listening(self) -> None:
212 | """
213 | Creates a RequestHandler and starts the listening for incoming requests. Will continue to listen for requests
214 | until self.__keep_running is set to False
215 |
216 | :return: None
217 | """
218 | def handler(*args: Any) -> None:
219 | SkyHookHTTPRequestHandler(skyhook_server=self, *args)
220 |
221 | self.__http_server = HTTPServer(("127.0.0.1", self.port), handler)
222 | logger.info(f"Started SkyHook on port: {self.port}")
223 | while self.__keep_running:
224 | self.__http_server.handle_request()
225 | logger.info("Shutting down server")
226 | self.__http_server.server_close()
227 | logger.info("Server shut down")
228 |
229 | def stop_listening(self) -> None:
230 | """
231 | Stops listening for incoming requests. Also emits the is_terminated event with "TERMINATED"
232 |
233 | :return: None
234 | """
235 | self.__keep_running = False
236 | if self.__http_server:
237 | self.__http_server.server_close()
238 | self.events.emit("is_terminated", "TERMINATED")
239 |
240 | def reload_modules(self) -> List[str]:
241 | """
242 | Reloads all the currently loaded modules. This can also be called remotely by sending
243 | ServerCommands.SKY_RELOAD_MODULES as the FunctionName.
244 |
245 | :return: List of reloaded module names
246 | """
247 | reload_module_names: List[str] = []
248 |
249 | for module in self.__loaded_modules:
250 | try:
251 | reload(module)
252 | reload_module_names.append(module.__name__)
253 | except:
254 | importlib.reload(module)
255 | return reload_module_names
256 |
257 | def hotload_module(self, module_name: Union[str, types.ModuleType], is_skyhook_module: bool = True) -> None:
258 | """
259 | Tries to load a module with module_name from skyhook.modules to be added to the server while it's running.
260 |
261 | :param module_name: *string* name of the module you want to load
262 | :param is_skyhook_module: *bool* name of the module you want to load
263 | :return: None
264 | """
265 | if isinstance(module_name, types.ModuleType):
266 | if is_skyhook_module:
267 | module_name = module_name.__name__.split(".")[-1]
268 | else:
269 | module_name = module_name.__name__
270 |
271 | try:
272 | if is_skyhook_module:
273 | mod: types.ModuleType = importlib.import_module(f".modules.{module_name}", package="skyhook")
274 | else:
275 | mod = importlib.import_module(module_name)
276 |
277 | if not mod in self.__loaded_modules:
278 | self.__loaded_modules.append(mod)
279 | logger.info(f"Added {mod}")
280 | logger.info(self.__loaded_modules)
281 | except Exception as err:
282 | logger.error(f"Failed to hotload: {module_name}")
283 | logger.error(err)
284 |
285 | def unload_modules(self, module_name: str, is_skyhook_module: bool = True) -> None:
286 | """
287 | Removes a module from the server
288 |
289 | :param module_name: *string* name of the module you want to remove
290 | :param is_skyhook_module: *bool* set this to true if the module you want to remove is a skyhook module
291 | :return: None
292 | """
293 | if is_skyhook_module:
294 | full_name: str = "skyhook.modules." + module_name
295 | else:
296 | full_name = module_name
297 | for module in self.__loaded_modules:
298 | if module.__name__ == full_name:
299 | self.__loaded_modules.remove(module)
300 |
301 | def get_function_by_name(self, function_name: str, module_name: Optional[str] = None) -> Callable[..., Any]:
302 | """
303 | Tries to find a function by name.
304 |
305 | :param function_name: *string* name of the function you're looking for
306 | :param module_name: *string* name of the module to search in. If None, all modules are searched
307 | :return: the actual function that matches your name, or None
308 | """
309 | if module_name is not None:
310 | module = sys.modules.get(f".modules.{module_name}")
311 | if module is not None:
312 | modules = [module]
313 | else:
314 | module = sys.modules.get(module_name)
315 | modules = [module]
316 | else:
317 | modules = self.__loaded_modules
318 |
319 | for module in modules:
320 | for name, value in module.__dict__.items():
321 | if callable(value):
322 | if function_name == name:
323 | return value
324 |
325 | # If no function is found, return a dummy function that raises an error
326 | def function_not_found(*args: Any, **kwargs: Any) -> None:
327 | raise ValueError(f"Function {function_name} not found")
328 |
329 | return function_not_found
330 |
331 | def filter_and_execute_function(self, function_name: str, parameters_dict: Dict[str, Any]) -> bytes:
332 | """
333 | This function decides whether or the function call should come from one of the loaded modules or from the server.
334 | Every server function should start with SKY_
335 |
336 | You shouldn't call this function directly.
337 |
338 | :param function_name: Name of the function to execute
339 | :param parameters_dict: Dictionary of parameters to pass to the function
340 | :return: JSON response as bytes
341 | """
342 | if function_name in dir(ServerCommands):
343 | result_json: Dict[str, Any] = self.__process_server_command(function_name, parameters_dict)
344 | else:
345 | result_json = self.__process_module_command(function_name, parameters_dict)
346 |
347 | self.executor_reply = None
348 | return json.dumps(result_json).encode()
349 |
350 | def __process_server_command(self, function_name: str, parameters_dict: Dict[str, Any] = {}) -> Dict[str, Any]:
351 | """
352 | Processes a command if the function in it was a server function.
353 |
354 | You shouldn't call this function directly
355 |
356 | :param function_name: Name of the server command to execute
357 | :param parameters_dict: Dictionary of parameters for the command
358 | :return: Result JSON dictionary
359 | """
360 | result_json: Dict[str, Any] = make_result_json(False, Errors.SERVER_COMMAND, None)
361 |
362 | if function_name == ServerCommands.SKY_SHUTDOWN:
363 | self.stop_listening()
364 | result_json = make_result_json(True, "Server offline", ServerCommands.SKY_SHUTDOWN)
365 |
366 | elif function_name == ServerCommands.SKY_LS:
367 | functions: List[str] = []
368 | for module in self.__loaded_modules:
369 | functions.extend([func for func in dir(module) if not "__" in func or isinstance(func, types.FunctionType)])
370 | result_json = make_result_json(True, functions, ServerCommands.SKY_LS)
371 |
372 | elif function_name == ServerCommands.SKY_RELOAD_MODULES:
373 | reloaded_modules: List[str] = self.reload_modules()
374 | result_json = make_result_json(True, reloaded_modules, ServerCommands.SKY_RELOAD_MODULES)
375 |
376 | elif function_name == ServerCommands.SKY_UNLOAD:
377 | modules: List[str] = parameters_dict.get(Constants.module, [])
378 | for module in modules:
379 | self.unload_modules(module)
380 |
381 | result_json = make_result_json(True, [m.__name__ for m in self.__loaded_modules], ServerCommands.SKY_UNLOAD)
382 |
383 | elif function_name == ServerCommands.SKY_FUNCTION_HELP:
384 | function_name_param: str = parameters_dict.get("function_name", "")
385 | function: Callable[..., Any] = self.get_function_by_name(function_name_param)
386 | arg_spec = inspect.getfullargspec(function)
387 |
388 | arg_spec_dict: Dict[str, Any] = {
389 | "function_name": function_name_param,
390 | "arguments": arg_spec.args
391 | }
392 |
393 | if arg_spec.varargs is not None:
394 | arg_spec_dict["packed_args"] = f"*{arg_spec.varargs}"
395 | if arg_spec.keywords is not None:
396 | arg_spec_dict["packed_kwargs"] = f"**{arg_spec.keywords}"
397 |
398 | result_json = make_result_json(True, arg_spec_dict, ServerCommands.SKY_FUNCTION_HELP)
399 |
400 | self.events.emit(ServerEvents.command, function_name, {})
401 | logger.success(f"Executed {function_name}")
402 |
403 | return result_json
404 |
405 | def __process_module_command(self, function_name: str, parameters_dict: Dict[str, Any], timeout: float = 10.0) -> Dict[str, Any]:
406 | """
407 | Processes a command if the function in it was a module function.
408 |
409 | You shouldn't call this function directly
410 |
411 | :param function_name: Name of the function to execute
412 | :param parameters_dict: Dictionary of parameters for the function
413 | :param timeout: Timeout in seconds for waiting for executor reply
414 | :return: Result JSON dictionary
415 | """
416 | if self.__use_main_thread_executor:
417 | logger.debug("Emitting exec_command event")
418 | self.events.emit(ServerEvents.exec_command, function_name, parameters_dict)
419 | start_time: float = time.time()
420 |
421 | while self.executor_reply is None:
422 | # wait for the executor to reply, just keep checking the time to see if we've waited longer
423 | # than timeout permits
424 | current_time: float = time.time()
425 | if current_time - start_time > timeout:
426 | self.executor_reply = make_result_json(success=False, return_value=Errors.TIMEOUT, command=function_name)
427 | return self.executor_reply
428 |
429 | module: Optional[str] = parameters_dict.get(Constants.module)
430 | parameters_dict.pop(Constants.module, None) # remove module from the parameters, otherwise it gets passed to the function
431 | function: Callable[..., Any] = self.get_function_by_name(function_name, module)
432 |
433 | try:
434 | return_value: Any = function(**parameters_dict)
435 | success: bool = True
436 | self.events.emit(ServerEvents.command, function_name, parameters_dict)
437 | except Exception as err:
438 | trace: str = str(traceback.format_exc())
439 | return_value = trace
440 | success = False
441 |
442 | result_json: Dict[str, Any] = make_result_json(success, return_value, function_name)
443 | return result_json
444 |
445 | def __get_host_program_port(self, host_program: Optional[str]) -> int:
446 | """
447 | Looks through the Constants.Ports class to try and find the port for the host program. If it can't, it will
448 | default to Constants.Ports.undefined
449 |
450 | You shouldn't call this function directly
451 |
452 | :param host_program: *string* name of the host program
453 | :return: Port number as integer
454 | """
455 | try:
456 | if host_program is not None:
457 | return getattr(Ports, host_program)
458 | return getattr(Ports, Constants.undefined)
459 | except Exception:
460 | return getattr(Ports, Constants.undefined)
461 |
462 |
463 | class SkyHookHTTPRequestHandler(BaseHTTPRequestHandler):
464 | """
465 | The class that handles requests coming into the server. It holds a reference to the Server class, so
466 | it can call the Server functions after it has processed the request.
467 | """
468 | skyhook_server: Server
469 |
470 | def __init__(
471 | self,
472 | request: socket.socket,
473 | client_address: tuple,
474 | server: HTTPServer,
475 | skyhook_server: Optional[Server] = None,
476 | reply_with_auto_close: bool = True
477 | ) -> None:
478 |
479 | self.skyhook_server = skyhook_server
480 | self.reply_with_auto_close = reply_with_auto_close
481 | self.quiet = False
482 | super().__init__(request, client_address, server)
483 |
484 | def do_GET(self) -> None:
485 | """
486 | Handle a GET request, this would be just a browser requesting a particular url
487 | like: http://localhost:65500/%22echo_message%22&%7B%22message%22:%22Hooking%20into%20the%20Sky%22%7D
488 | """
489 | # browsers tend to send a GET for the favicon as well, which we don't care about
490 | if self.path == "/favicon.ico":
491 | return
492 |
493 | data: str = urllib.parse.unquote(self.path).lstrip("/")
494 | parts: List[str] = data.split("&")
495 |
496 | try:
497 | function: str = eval(parts[0])
498 | parameters: Dict[str, Any] = json.loads(parts[1])
499 | except NameError as err:
500 | logger.warning(f"Got a GET request that I don't know what to do with")
501 | logger.warning(f"Request was: GET {data}")
502 | logger.warning(f"Error is : {err}")
503 | return
504 |
505 | command_response: bytes = self.skyhook_server.filter_and_execute_function(function, parameters)
506 | self.send_response_data("GET")
507 | self.wfile.write(bytes(f"{command_response}".encode("utf-8")))
508 | if self.reply_with_auto_close:
509 | # reply back to the browser with a javascript that will close the window (tab) it just opened
510 | self.wfile.write(bytes("".encode("utf-8")))
511 |
512 | def do_POST(self) -> None:
513 | """
514 | Handle a post request, expects a JSON object to be sent in the POST data
515 | """
516 | content_length: int = int(self.headers['Content-Length'])
517 | post_data: bytes = self.rfile.read(content_length)
518 | decoded_data: str = post_data.decode('utf-8')
519 | json_data: Dict[str, Any] = json.loads(decoded_data)
520 |
521 | function: str = json_data.get(Constants.function_name, "")
522 | parameters: Dict[str, Any] = json_data.get(Constants.parameters, {})
523 |
524 | command_response: bytes = self.skyhook_server.filter_and_execute_function(function, parameters)
525 | self.send_response_data("POST")
526 | self.wfile.write(command_response)
527 |
528 | def send_response_data(self, request_type: str) -> None:
529 | """
530 | Generates the response and appropriate headers to send back to the client
531 |
532 | :param request_type: *string* GET or POST
533 | """
534 | self.send_response(200)
535 | if request_type == "GET":
536 | self.send_header('Content-type', 'text/html')
537 | elif request_type == "POST":
538 | self.send_header('Content-type', 'application/json')
539 | else:
540 | pass
541 | self.end_headers()
542 |
543 | def log_message(self, fmt, *args):
544 | if not self.quiet:
545 | super().log_message(fmt, *args)
546 |
547 |
548 | def port_in_use(port_number: int, host: str = "127.0.0.1", timeout: float = 0.125) -> bool:
549 | """
550 | Checks if a specific ports is already in use by a different process. Useful if you want to check if you can start
551 | a SkyHook server on a default port that might already be in use.
552 |
553 | :param port_number: Port you want to check
554 | :param host: Hostname, default is 127.0.0.1
555 | :param timeout: when the socket should time out
556 | :return: True if port is in use, False otherwise
557 | """
558 | sock: socket.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
559 | sock.settimeout(timeout)
560 | result: int = sock.connect_ex((host, port_number))
561 | if result == 0:
562 | return True
563 | return False
564 |
565 |
566 | def make_result_json(success: bool, return_value: Any, command: str) -> Dict[str, Any]:
567 | """
568 | Little helper function to construct a json for the server to send back after a POST request
569 |
570 | :param success: Whether the command was successful
571 | :param return_value: The return value of the command
572 | :param command: The command that was executed
573 | :return: Dictionary with result information
574 | """
575 | result_json: Dict[str, Any] = {
576 | Results.time: datetime.now().strftime("%H:%M:%S"),
577 | Results.success: success,
578 | Results.return_value: return_value,
579 | Results.command: command
580 | }
581 |
582 | return result_json
583 |
584 |
585 | def start_server_in_thread(
586 | host_program: str = "",
587 | port: Optional[int] = None,
588 | load_modules: List[str] = [],
589 | echo_response: bool = False
590 | ) -> List[Optional[Union[threading.Thread, Server]]]:
591 | """
592 | Starts a server in a separate thread.
593 |
594 | :param host_program: *string* name of the host program
595 | :param port: *int* port override
596 | :param load_modules: *list* modules to load
597 | :param echo_response: *bool* print the response the server is sending back
598 | :return: *List* containing Thread and Server or None values if server couldn't start
599 | """
600 | if host_program != "":
601 | port = getattr(Ports, host_program)
602 |
603 | if port_in_use(port):
604 | logger.error(f"Port {port} is already in use, can't start server")
605 | return [None, None]
606 |
607 | skyhook_server: Server = Server(host_program=host_program, port=port, load_modules=load_modules, echo_response=echo_response)
608 |
609 | def kill_thread_callback(message: str) -> None:
610 | logger.info("\nShyhook server thread was killed")
611 |
612 | skyhook_server.events.connect(ServerEvents.is_terminated, kill_thread_callback)
613 |
614 | thread: threading.Thread = threading.Thread(target=skyhook_server.start_listening)
615 | thread.daemon = True
616 | thread.start()
617 |
618 | return [thread, skyhook_server]
619 |
620 |
621 | def start_executor_server_in_thread(
622 | host_program: str = "",
623 | port: Optional[int] = None,
624 | load_modules: List[str] = [],
625 | echo_response: bool = False,
626 | executor: Optional[GenericMainThreadExecutor] = None,
627 | executor_name: Optional[str] = None
628 | ) -> List[Optional[Union[threading.Thread, GenericMainThreadExecutor, Server]]]:
629 | """
630 | Starts a server in a thread, but moves all executing functionality to a MainThreadExecutor object. Use this
631 | for a host program where executing code in a thread other than the main thread is not safe. Eg: Blender
632 |
633 | :param host_program: *string* name of the host program
634 | :param port: *int* port override
635 | :param load_modules: *list* modules to load
636 | :param echo_response: *bool* print the response the server is sending back
637 | :param executor: Optional executor instance to use
638 | :param executor_name: Name of an executor to use, for example "maya" or "blender"
639 | :return: *List* containing Thread, MainThreadExecutor and Server or None values if server couldn't start
640 | """
641 | if host_program != "":
642 | port = getattr(Ports, host_program)
643 |
644 | if port is None:
645 | port = getattr(Ports, Constants.undefined)
646 |
647 | if port_in_use(port):
648 | logger.error(f"Port {port} is already in use, can't start server")
649 | return [None, None, None]
650 |
651 | skyhook_server: Server = Server(host_program=host_program, port=port, load_modules=load_modules,
652 | use_main_thread_executor=True, echo_response=echo_response)
653 |
654 | executor_mapping: Dict[str, Type[GenericMainThreadExecutor]] = {
655 | HostPrograms.maya: MayaExecutor,
656 | HostPrograms.blender: BlenderExecutor,
657 | }
658 |
659 | # if we didn't specify an executor to use, figure out which one we should make
660 | if executor is None:
661 | executor_key: Optional[str] = None
662 |
663 | # executor_name > host_program > default
664 | if executor_name is not None:
665 | executor_key = executor_name
666 | logger.info(f"Using executor specified by name: {executor_name}")
667 | elif host_program:
668 | executor_key = host_program
669 | logger.info(f"Using executor based on host program: {host_program}")
670 |
671 | # we found which one to use in the mapping
672 | if executor_key in executor_mapping:
673 | executor_class = executor_mapping[executor_key]
674 | # make the executor
675 | executor = executor_class(skyhook_server)
676 | logger.info(f"Created {executor_class.__name__}")
677 | # we couldn't find one, make a GenericMainThreadExecutor
678 | else:
679 | logger.warning("No specific executor found, using GenericMainThreadExecutor")
680 | executor = GenericMainThreadExecutor(skyhook_server)
681 |
682 | # Connect the exec_command event to the executor's execute method
683 | skyhook_server.events.connect(ServerEvents.exec_command, executor.execute)
684 |
685 | def kill_thread_callback(message: str) -> None:
686 | logger.info("\nSkyhook server thread was killed")
687 |
688 | skyhook_server.events.connect("is_terminated", kill_thread_callback)
689 |
690 | # running the server on a separate thread
691 | thread: threading.Thread = threading.Thread(target=skyhook_server.start_listening)
692 | thread.daemon = True
693 | thread.start()
694 |
695 | return [thread, executor, skyhook_server]
696 |
697 |
698 | def start_blocking_server(
699 | host_program: str = "",
700 | port: Optional[int] = None,
701 | load_modules: List[str] = [],
702 | echo_response: bool = False
703 | ) -> Optional[Server]:
704 | """
705 | Starts a server in the main thread. This will block your application. Use this when you don't care about your
706 | application being locked up.
707 |
708 | :param host_program: *string* name of the host program
709 | :param port: *int* port override
710 | :param load_modules: *list* modules to load
711 | :param echo_response: *bool* print the response the server is sending back
712 | :return: Server instance or None if server couldn't start
713 | """
714 | if host_program != "":
715 | port = getattr(Ports, host_program)
716 |
717 | if port is None:
718 | port = getattr(Ports, Constants.undefined)
719 |
720 | if port_in_use(port):
721 | logger.error(f"Port {port} is already in use, can't start server")
722 | return None
723 |
724 | skyhook_server: Server = Server(host_program=host_program, port=port, load_modules=load_modules, echo_response=echo_response)
725 | skyhook_server.start_listening()
726 | return skyhook_server
--------------------------------------------------------------------------------
/wiki-images/UE_Logo_Icon_Black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/UE_Logo_Icon_Black.png
--------------------------------------------------------------------------------
/wiki-images/blender_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/blender_logo.png
--------------------------------------------------------------------------------
/wiki-images/dragon_martin_woortman.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/dragon_martin_woortman.png
--------------------------------------------------------------------------------
/wiki-images/houdinibadge.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/houdinibadge.jpg
--------------------------------------------------------------------------------
/wiki-images/houdinibadge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/houdinibadge.png
--------------------------------------------------------------------------------
/wiki-images/maya_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/maya_logo.png
--------------------------------------------------------------------------------
/wiki-images/substance_painter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EmbarkStudios/skyhook/6a027e1a407d015e31e3fc37d4836487a7a1689f/wiki-images/substance_painter.png
--------------------------------------------------------------------------------